fix(ct): по ссылке и без сессии «Наименование ТМЦ» берётся из КП, а не из заявки

При открытии СТ по ссылке (согласующий) или в новой вкладке предложения собирались из SP без
поля supplierPosition, и шаг 3 подставлял вместо названия из КП название позиции заявки.
Согласующий не видел, что поставщик предложил другую модель или аналог, а автор, сохранив
шаг 3 без сессии, записывал в SP название заявки поверх названия поставщика. Теперь название
читается из SP и переносится (сборка офферов вынесена в buildOffersFromSpRecords).
This commit is contained in:
Тимур Городецкий
2026-10-01 14:28:32 +05:00
parent 85145e0d6e
commit 6ef39f8fc9
4 changed files with 123 additions and 18 deletions
@@ -9,6 +9,7 @@ import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOf
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
@@ -124,23 +125,10 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
const offers = rawOffers.map(o => {
const offerIdNum = o.offerFileSpId ?? offerIdBySupplierSpId.get(o.supplierSpId);
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
return {
id: '',
supplierId: columnIdOf(o.supplierSpId, o.offerFileSpId),
positionId: String(o.positionSpId),
itemIndex: o.itemIndex,
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
currencyCode,
pricePerUnitKzt: o.priceTenge || o.price,
totalPriceKzt: o.summaTenge,
totalWithExtraKzt: o.summaTenge,
offerId: offerIdNum !== undefined ? String(offerIdNum) : undefined,
};
const offers = buildOffersFromSpRecords(rawOffers, {
columnIdOf,
currencyCodeOf,
fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId),
});
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
@@ -0,0 +1,66 @@
import { SpOfferRecord, buildOffersFromSpRecords } from './spOffers';
function makeRecord(overrides: Partial<SpOfferRecord>): SpOfferRecord {
return {
positionSpId: 1543,
supplierSpId: 263,
supplierName: 'ТОО «Арлан Офис Снаб»',
supplierPosition: 'Доска пробковая в раме 90х120',
price: 28500,
quantity: 4,
unit: 'шт.',
priceTenge: 28500,
summaTenge: 114000,
currencyCode: 'KZT',
itemIndex: 0,
offerFileSpId: 277,
...overrides,
};
}
const deps = {
columnIdOf: (supplierSpId: number, offerFileSpId?: number): string => `${supplierSpId}_${offerFileSpId}`,
currencyCodeOf: (): string => 'KZT',
fallbackOfferFileId: (): number | undefined => undefined,
};
describe('buildOffersFromSpRecords (открытие СТ по ссылке / без сессии)', () => {
// Раньше название из КП терялось: шаг 3 подставлял название из заявки, а «Сохранить» записывало
// его в SP поверх названия поставщика — согласующий не видел, что предложена другая модель.
it('сохраняет наименование ТМЦ из КП', () => {
const [offer] = buildOffersFromSpRecords([makeRecord({})], deps);
expect(offer.supplierPosition).toBe('Доска пробковая в раме 90х120');
});
it('пустое наименование из КП не подменяет названием из заявки', () => {
const [offer] = buildOffersFromSpRecords([makeRecord({ supplierPosition: '' })], deps);
expect(offer.supplierPosition).toBeUndefined();
});
it('переносит колонку, позицию, цены и файл КП', () => {
const [offer] = buildOffersFromSpRecords([makeRecord({})], deps);
expect(offer).toMatchObject({
supplierId: '263_277',
positionId: '1543',
itemIndex: 0,
unit: 'шт.',
pricePerUnit: 28500,
quantity: 4,
pricePerUnitKzt: 28500,
totalPriceKzt: 114000,
offerId: '277',
});
});
it('без файла в записи берёт файл КП из сессии', () => {
const [offer] = buildOffersFromSpRecords(
[makeRecord({ offerFileSpId: undefined })],
{ ...deps, fallbackOfferFileId: () => 300 },
);
expect(offer.offerId).toBe('300');
});
});
@@ -0,0 +1,49 @@
import { SupplierOffer } from '../types/domain';
// Запись «Оценка поставщиков», прочитанная из SP (CtService.loadFullOffersForPositions).
export interface SpOfferRecord {
positionSpId: number;
supplierSpId: number;
supplierName: string;
supplierPosition: string; // наименование ТМЦ в КП поставщика
price: number;
quantity: number;
unit: string;
priceTenge: number;
summaTenge: number;
currencyCode: string;
itemIndex: number;
valutaSpId?: number;
offerFileSpId?: number;
}
export interface BuildOffersDeps {
columnIdOf: (supplierSpId: number, offerFileSpId?: number) => string;
currencyCodeOf: (valutaSpId?: number) => string;
// Файл КП из сессии — для старых записей без offerFileId.
fallbackOfferFileId: (supplierSpId: number) => number | undefined;
}
// Офферы формы из записей SP (открытие СТ по ссылке / без сессии). Наименование ТМЦ из КП переносим
// обязательно: без него шаг 3 подставляет название из заявки — согласующий не видит, что поставщик
// предложил другую модель, а «Сохранить» записывает название заявки в SP поверх названия поставщика.
export function buildOffersFromSpRecords(records: SpOfferRecord[], deps: BuildOffersDeps): SupplierOffer[] {
return records.map(r => {
const offerFileId = r.offerFileSpId ?? deps.fallbackOfferFileId(r.supplierSpId);
return {
id: '',
supplierId: deps.columnIdOf(r.supplierSpId, r.offerFileSpId),
positionId: String(r.positionSpId),
itemIndex: r.itemIndex,
supplierPosition: r.supplierPosition || undefined,
unit: r.unit,
pricePerUnit: r.price,
quantity: r.quantity,
currencyCode: deps.currencyCodeOf(r.valutaSpId),
pricePerUnitKzt: r.priceTenge || r.price,
totalPriceKzt: r.summaTenge,
totalWithExtraKzt: r.summaTenge,
offerId: offerFileId !== undefined ? String(offerFileId) : undefined,
};
});
}
@@ -779,6 +779,7 @@ export class CtService {
positionSpId: number;
supplierSpId: number;
supplierName: string;
supplierPosition: string;
price: number;
quantity: number;
unit: string;
@@ -799,7 +800,7 @@ export class CtService {
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
const url =
`${this._li(LISTS.OFFERS)}` +
`?$select=Id,Title,positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` +
`?$select=Id,Title,positionId,supplierId,supplierPosition,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` +
`&$expand=supplier` +
`&$filter=${filter}` +
`&$top=2000`;
@@ -816,6 +817,7 @@ export class CtService {
positionSpId: Number(item["positionId"]),
supplierSpId: Number(item["supplierId"]),
supplierName: String(sup?.["Title"] ?? ""),
supplierPosition: String(item["supplierPosition"] ?? ""),
price: Number(item["price"] ?? 0),
quantity: Number(item["quantity"] ?? 0),
unit: String(item["unit"] ?? ""),