feat(ct): несколько КП одного поставщика — отдельные колонки в таблице сравнения

Колонка таблицы сравнения теперь «поставщик + файл КП» (id `${spId}_${kpFileSpId}`), а не только
поставщик. Два КП одного поставщика (напр. в KZT и USD) раньше схлопывались в одну колонку —
оставался последний файл, второе предложение пропадало. Теперь каждый КП — своя колонка со своей
валютой; записи в SP различаются по offerFileId: дедуп и чистка повторной обработки не трогают
записи другого КП того же поставщика, восстановление по ссылке раскладывает их по колонкам.
Старые колонки без файла (данные до изменения) работают как раньше.

Этап 1 из 4: выбора «заменить / добавить отдельно» пока нет — второй КП поставщика всегда
идёт отдельной колонкой.
This commit is contained in:
Тимур Городецкий
2026-10-01 11:52:35 +05:00
parent 9490d5b83f
commit 3e95f1470f
15 changed files with 306 additions and 99 deletions
@@ -8,6 +8,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers'; import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable'; import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval'; import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import styles from './CtCreateForm.module.scss'; import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css'; import '@tnsu/ui-kit/styles.css';
@@ -85,17 +86,23 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
const rateById = new Map(loadedRates.map(r => [Number(r.id), r])); const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
const spCurrencyRates = loadedRates; const spCurrencyRates = loadedRates;
// Build unique suppliers from offers (used only as fallback when session has none) // Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько
const supplierCurrencyMap = new Map<number, string>(); // КП в разных валютах. Используются, только когда в сессии колонок нет.
rawOffers.forEach(o => { const currencyCodeOf = (valutaSpId?: number): string =>
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT'); valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
}); const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
supplierSpId: o.supplierSpId,
supplierName: o.supplierName,
offerFileSpId: o.offerFileSpId,
currencyCode: currencyCodeOf(o.valutaSpId),
})));
const supplierMap = new Map<number, string>(); const supplierMap = new Map<number, string>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); }); rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({ // Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
id: String(spId), spId, name, hasOffer: true as const, const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
currency: supplierCurrencyMap.get(spId) ?? 'KZT', const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
})); findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key // Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an // (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
@@ -106,26 +113,23 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => { [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id); if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
}); });
const supReactIdBySpId = new Map<number, string>();
formData.suppliers.forEach(s => {
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
});
const step3DeliveryKm: Record<string, number> = {}; const step3DeliveryKm: Record<string, number> = {};
rawOffers.forEach(o => { rawOffers.forEach(o => {
if (o.deliveryDistanceKm !== undefined) { if (o.deliveryDistanceKm !== undefined) {
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId); const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId); const supKey = columnIdOf(o.supplierSpId, o.offerFileSpId);
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm; step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
} }
}); });
// Build offers in domain format, restoring offerId from session // Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
const offers = rawOffers.map(o => { const offers = rawOffers.map(o => {
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId) ?? o.offerFileSpId; const offerIdNum = o.offerFileSpId ?? offerIdBySupplierSpId.get(o.supplierSpId);
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT'; const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
return { return {
id: '', id: '',
supplierId: String(o.supplierSpId), supplierId: columnIdOf(o.supplierSpId, o.offerFileSpId),
positionId: String(o.positionSpId), positionId: String(o.positionSpId),
itemIndex: o.itemIndex, itemIndex: o.itemIndex,
unit: o.unit, unit: o.unit,
@@ -75,18 +75,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии, // Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой. // синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
// Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах.
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => { const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId); const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
if (!uploaded?.supplierName) return; if (!uploaded?.supplierName) return;
onSave(applySupplierCurrency( onSave(applySupplierCurrency(
formData.suppliers ?? [], formData.suppliers ?? [],
formData.offers ?? [], formData.offers ?? [],
uploaded.supplierName, uploaded,
currencyCode, currencyCode,
currencyRates, currencyRates,
)); ));
kp.kpFiles kp.kpFiles
.filter(f => f.supplierName === uploaded.supplierName) .filter(f => f.spId === fileSpId)
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode)); .forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
}; };
@@ -152,7 +153,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
files={formData.uploadedKpFiles} files={formData.uploadedKpFiles}
service={service} service={service}
currencyOptions={currencyOptions} currencyOptions={currencyOptions}
getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)} getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
onCurrencyChange={handleUploadedCurrencyChange} onCurrencyChange={handleUploadedCurrencyChange}
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })} onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
disabled={isNotificationLink || isReadOnly} disabled={isNotificationLink || isReadOnly}
@@ -2,13 +2,14 @@ import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit'; import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { KpFileRef } from './kpCurrency';
import styles from './Step2CommercialOffers.module.scss'; import styles from './Step2CommercialOffers.module.scss';
interface Props { interface Props {
files: CTFormData['uploadedKpFiles']; files: CTFormData['uploadedKpFiles'];
service: CtService; service: CtService;
currencyOptions: string[]; currencyOptions: string[];
getCurrency: (supplierName: string | undefined) => string; getCurrency: (file: KpFileRef) => string;
onCurrencyChange: (spId: number, currencyCode: string) => void; onCurrencyChange: (spId: number, currencyCode: string) => void;
onRemove: (spId: number) => void; onRemove: (spId: number) => void;
disabled?: boolean; disabled?: boolean;
@@ -40,12 +41,12 @@ export const UploadedKpList: React.FC<Props> = ({
{f.name} {f.name}
</a> </a>
{disabled ? ( {disabled ? (
<span className={styles.kpCurrencyCode}>{getCurrency(f.supplierName)}</span> <span className={styles.kpCurrencyCode}>{getCurrency(f)}</span>
) : ( ) : (
<> <>
<select <select
className={styles.kpCurrencySelect} className={styles.kpCurrencySelect}
value={getCurrency(f.supplierName)} value={getCurrency(f)}
onChange={e => onCurrencyChange(f.spId, e.target.value)} onChange={e => onCurrencyChange(f.spId, e.target.value)}
// Валюта хранится на поставщике — без него менять нечего. // Валюта хранится на поставщике — без него менять нечего.
disabled={!f.supplierName} disabled={!f.supplierName}
@@ -0,0 +1,47 @@
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
const USD: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 440 };
const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
const USD_COLUMN: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала»', hasOffer: true, currency: 'KZT' };
function makeOffer(supplierId: string): SupplierOffer {
return {
id: '', supplierId, positionId: 'p1', pricePerUnit: 10, quantity: 2, currencyCode: 'KZT',
pricePerUnitKzt: 10, totalPriceKzt: 20, totalWithExtraKzt: 20,
};
}
describe('applySupplierCurrency', () => {
// Два КП одного поставщика: смена валюты одного файла не должна пересчитать цены другого.
it('меняет валюту только у колонки этого файла КП', () => {
const { suppliers, offers } = applySupplierCurrency(
[KZT_COLUMN, USD_COLUMN],
[makeOffer('263_277'), makeOffer('263_290')],
{ spId: 290, supplierName: 'ТОО «Арлан»' },
'USD',
[USD],
);
expect(suppliers.map(s => s.currency)).toEqual(['KZT', 'USD']);
expect(offers[0].pricePerUnitKzt).toBe(10);
expect(offers[1]).toMatchObject({ currencyCode: 'USD', pricePerUnitKzt: 4400, totalPriceKzt: 8800 });
});
it('старая колонка без файла находится по имени поставщика', () => {
const { suppliers } = applySupplierCurrency([LEGACY], [], { spId: 278, supplierName: 'ТОО «Кала»' }, 'USD', [USD]);
expect(suppliers[0].currency).toBe('USD');
});
});
describe('getSupplierCurrency', () => {
it('валюта колонки этого файла КП', () => {
const columns = [KZT_COLUMN, { ...USD_COLUMN, currency: 'USD' }];
expect(getSupplierCurrency(columns, { spId: 290, supplierName: 'ТОО «Арлан»' })).toBe('USD');
expect(getSupplierCurrency(columns, { spId: 277, supplierName: 'ТОО «Арлан»' })).toBe('KZT');
});
});
@@ -2,28 +2,40 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
const BASE_CURRENCY = 'KZT'; const BASE_CURRENCY = 'KZT';
// Валюта хранится на поставщике (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП // Файл из списка «Загруженные КП», чью валюту меняют/читают.
export interface KpFileRef {
spId: number;
supplierName?: string;
}
// Колонки этого файла КП. У поставщика может быть несколько КП (напр. KZT и USD) — валюта у каждой
// колонки своя. Старые колонки без файла (kpFileSpId) ищем по имени поставщика, как раньше.
function findFileColumns(suppliers: Supplier[], file: KpFileRef): Supplier[] {
const byFile = suppliers.filter(s => s.hasOffer && s.kpFileSpId === file.spId);
if (byFile.length > 0) return byFile;
return suppliers.filter(s => s.hasOffer && s.kpFileSpId === undefined && s.name === file.supplierName);
}
// Валюта хранится на колонке поставщика (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы // и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту. // таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
export function applySupplierCurrency( export function applySupplierCurrency(
suppliers: Supplier[], suppliers: Supplier[],
offers: SupplierOffer[], offers: SupplierOffer[],
supplierName: string, file: KpFileRef,
currencyCode: string, currencyCode: string,
currencyRates: CurrencyRate[], currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } { ): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const supplierIds = new Set( const columnIds = new Set(findFileColumns(suppliers, file).map(s => s.id));
suppliers.filter(s => s.hasOffer && s.name === supplierName).map(s => s.id),
);
const rate = currencyCode === BASE_CURRENCY const rate = currencyCode === BASE_CURRENCY
? 1 ? 1
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1); : (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
const nextSuppliers = suppliers.map(s => const nextSuppliers = suppliers.map(s =>
supplierIds.has(s.id) ? { ...s, currency: currencyCode } : s, columnIds.has(s.id) ? { ...s, currency: currencyCode } : s,
); );
const nextOffers = offers.map(o => { const nextOffers = offers.map(o => {
if (!supplierIds.has(o.supplierId)) return o; if (!columnIds.has(o.supplierId)) return o;
const pricePerUnitKzt = o.pricePerUnit * rate; const pricePerUnitKzt = o.pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * o.quantity; const totalPriceKzt = pricePerUnitKzt * o.quantity;
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt }; return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
@@ -32,7 +44,6 @@ export function applySupplierCurrency(
return { suppliers: nextSuppliers, offers: nextOffers }; return { suppliers: nextSuppliers, offers: nextOffers };
} }
export function getSupplierCurrency(suppliers: Supplier[], supplierName: string | undefined): string { export function getSupplierCurrency(suppliers: Supplier[], file: KpFileRef): string {
const supplier = suppliers.find(s => s.hasOffer && s.name === supplierName); return findFileColumns(suppliers, file)[0]?.currency ?? BASE_CURRENCY;
return supplier?.currency ?? BASE_CURRENCY;
} }
@@ -28,11 +28,11 @@ describe('buildKpSuppliersAndOffers', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []); const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
expect(suppliers).toEqual([ expect(suppliers).toEqual([
{ id: '10', spId: 10, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' }, { id: '10_100', spId: 10, kpFileSpId: 100, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
]); ]);
expect(offers).toHaveLength(1); expect(offers).toHaveLength(1);
expect(offers[0]).toMatchObject({ expect(offers[0]).toMatchObject({
supplierId: '10', supplierId: '10_100',
positionId: 'pos_1', positionId: 'pos_1',
supplierPosition: 'Кабель', supplierPosition: 'Кабель',
pricePerUnit: 200, pricePerUnit: 200,
@@ -43,41 +43,27 @@ describe('buildKpSuppliersAndOffers', () => {
}); });
}); });
// Регресс 5f5f0e7: два КП одного поставщика давали дубль колонки и задвоенные строки позиций. // Два КП одного поставщика (напр. KZT и USD) — два отдельных предложения: по колонке на файл.
it('из нескольких КП одного поставщика берёт только последний', () => { // Раньше брался только последний файл (5f5f0e7), и второе предложение пропадало.
const first = makeKpFile({ localId: 'a', spId: 100 }); it('каждый КП поставщика — отдельная колонка со своими предложениями', () => {
const second = makeKpFile({ const kzt = makeKpFile({ localId: 'a', spId: 100 });
const usd = makeKpFile({
localId: 'b', localId: 'b',
spId: 101, spId: 101,
flowResults: [{ positionId: 'pos_1', flowResult: { price: 300, quantity: 5 } }], currencyCode: 'USD',
flowResults: [{ positionId: 'pos_1', flowResult: { price: 3, quantity: 5 } }],
}); });
const { suppliers, offers } = buildKpSuppliersAndOffers([first, second], []); const { suppliers, offers } = buildKpSuppliersAndOffers([kzt, usd], [USD_RATE]);
expect(suppliers).toHaveLength(1); expect(suppliers.map(s => ({ id: s.id, kpFileSpId: s.kpFileSpId, currency: s.currency }))).toEqual([
expect(offers).toHaveLength(1); { id: '10_100', kpFileSpId: 100, currency: 'KZT' },
expect(offers[0].offerId).toBe('101'); { id: '10_101', kpFileSpId: 101, currency: 'USD' },
expect(offers[0].pricePerUnit).toBe(300); ]);
}); expect(offers.map(o => ({ supplierId: o.supplierId, offerId: o.offerId, pricePerUnit: o.pricePerUnit }))).toEqual([
{ supplierId: '10_100', offerId: '100', pricePerUnit: 200 },
it('не вытесняет обработанный КП тем, что ещё в процессе', () => { { supplierId: '10_101', offerId: '101', pricePerUnit: 3 },
const done = makeKpFile({ localId: 'a', spId: 100, status: 'done' }); ]);
const inProgress = makeKpFile({ localId: 'b', spId: 101, status: 'flowing' });
const { offers } = buildKpSuppliersAndOffers([done, inProgress], []);
expect(offers).toHaveLength(1);
expect(offers[0].offerId).toBe('100');
});
it('заменяет КП в процессе на обработанный', () => {
const inProgress = makeKpFile({ localId: 'a', spId: 100, status: 'flowing' });
const done = makeKpFile({ localId: 'b', spId: 101, status: 'done' });
const { offers } = buildKpSuppliersAndOffers([inProgress, done], []);
expect(offers).toHaveLength(1);
expect(offers[0].offerId).toBe('101');
}); });
it('разных поставщиков оставляет отдельными колонками', () => { it('разных поставщиков оставляет отдельными колонками', () => {
@@ -86,7 +72,7 @@ describe('buildKpSuppliersAndOffers', () => {
const { suppliers } = buildKpSuppliersAndOffers([first, second], []); const { suppliers } = buildKpSuppliersAndOffers([first, second], []);
expect(suppliers.map(s => s.id)).toEqual(['10', '20']); expect(suppliers.map(s => s.id)).toEqual(['10_100', '20_100']);
}); });
it('пропускает файл, у которого поток ещё не определил поставщика', () => { it('пропускает файл, у которого поток ещё не определил поставщика', () => {
@@ -2,7 +2,7 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes'; import { KpFile, NoOfferEntry } from './kpTypes';
import { pickLatestKpFilePerSupplier } from '../../../../shared/offers/kpFiles'; import { buildSupplierColumnId } from '../utils/supplierColumns';
export interface PersistStep2Args { export interface PersistStep2Args {
service: CtService; service: CtService;
@@ -12,18 +12,23 @@ export interface PersistStep2Args {
noOfferSuppliers: NoOfferEntry[]; noOfferSuppliers: NoOfferEntry[];
} }
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2, // Строит колонки поставщиков и их офферы из результатов потока. Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен // чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»). // доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
// Колонка — на каждый файл КП: несколько КП одного поставщика (напр. KZT и USD) — отдельные
// предложения. Раньше брался только последний файл поставщика, и второе предложение пропадало.
export function buildKpSuppliersAndOffers( export function buildKpSuppliersAndOffers(
allKpFiles: KpFile[], allKpFiles: KpFile[],
currencyRates: CurrencyRate[], currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } { ): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const kpFiles = pickLatestKpFilePerSupplier(allKpFiles); const kpFiles = allKpFiles.filter(f => f.supplierName);
const columnIdOf = (f: KpFile): string =>
f.supplierSpId ? buildSupplierColumnId(f.supplierSpId, f.spId) : f.localId;
const suppliers: Supplier[] = kpFiles const suppliers: Supplier[] = kpFiles
.map(f => ({ .map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId, id: columnIdOf(f),
spId: f.supplierSpId, spId: f.supplierSpId,
kpFileSpId: f.spId,
name: f.supplierName as string, name: f.supplierName as string,
hasOffer: true, hasOffer: true,
currency: f.currencyCode, currency: f.currencyCode,
@@ -43,7 +48,7 @@ export function buildKpSuppliersAndOffers(
const totalPriceKzt = pricePerUnitKzt * quantity; const totalPriceKzt = pricePerUnitKzt * quantity;
offers.push({ offers.push({
id: '', id: '',
supplierId: String(f.supplierSpId), supplierId: columnIdOf(f),
positionId: fr.positionId, positionId: fr.positionId,
supplierPosition: fr.flowResult.position, supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit, unit: fr.flowResult.unit,
@@ -87,23 +92,25 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here // saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3. // before proceeding to Step 3.
if (newOffers.length > 0) { if (newOffers.length > 0) {
// id колонки («поставщик_файл») в SP не пишем — туда идут SP Id поставщика и позиции.
const offersForSP = newOffers const offersForSP = newOffers
.map(o => { .map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId); const pos = formData.selectedPositions.find(p => p.id === o.positionId);
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null; const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId;
if (pos?.spId === undefined || supplierSpId === undefined) return null;
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
}) })
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10))); .filter((o): o is SupplierOffer => o !== null);
if (offersForSP.length > 0) { if (offersForSP.length > 0) {
await service.saveOffers(offersForSP, savedRates); await service.saveOffers(offersForSP, savedRates);
} }
} }
// Мержим с поставщиками из предыдущих сессий на шаге 2 // Мержим с колонками из предыдущих сессий на шаге 2 — по id колонки (поставщик + файл КП):
// (повторная загрузка КП того же поставщика — заменяем по имени) // другой КП того же поставщика остаётся отдельной колонкой.
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id)); const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
const mergedSuppliersWithOffer: Supplier[] = [ const mergedSuppliersWithOffer: Supplier[] = [
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)), ...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierIds.has(s.id)),
...newSuppliersWithOffer, ...newSuppliersWithOffer,
]; ];
const mergedOffers: SupplierOffer[] = [ const mergedOffers: SupplierOffer[] = [
@@ -56,8 +56,9 @@ export function useKpProcessing(
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates); const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
if (kpSuppliers.length > 0) { if (kpSuppliers.length > 0) {
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем. // Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name)); // поставщика — отдельное предложение, своя колонка.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
patch.suppliers = [...others, ...kpSuppliers]; patch.suppliers = [...others, ...kpSuppliers];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id)); const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId)); const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
@@ -6,6 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { findColumnForRecord } from '../utils/supplierColumns';
let extraIdCounter = 0; let extraIdCounter = 0;
@@ -138,9 +139,13 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
service.loadCriteria(supplierSpIds) service.loadCriteria(supplierSpIds)
.then(rawCriteria => { .then(rawCriteria => {
const spMap: Record<string, CriteriaEntry> = {}; const spMap: Record<string, CriteriaEntry> = {};
// Критерии в SP — на поставщика, а не на КП: у всех колонок поставщика (несколько КП) они общие.
rawCriteria.forEach(c => { rawCriteria.forEach(c => {
const sup = suppliers.find(s => s.spId === c.supplierSpId); suppliers
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates }; .filter(s => s.spId === c.supplierSpId)
.forEach(sup => {
spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
});
}); });
// Merge SP data with any user edits from formData — user edits take priority // Merge SP data with any user edits from formData — user edits take priority
setCriteriaMap(prev => ({ ...spMap, ...prev })); setCriteriaMap(prev => ({ ...spMap, ...prev }));
@@ -165,7 +170,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const newEdits: EditableOffers = {}; const newEdits: EditableOffers = {};
rawOffers.forEach(raw => { rawOffers.forEach(raw => {
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId); const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
const sup = suppliers.find(s => s.spId === raw.supplierSpId); const sup = findColumnForRecord(suppliers, raw.supplierSpId, raw.offerFileSpId);
if (!pos || !sup) return; if (!pos || !sup) return;
const key = buildOfferKey(sup.id, pos.id); const key = buildOfferKey(sup.id, pos.id);
const item: CellEdit = { const item: CellEdit = {
@@ -221,7 +226,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const merged = currentOffers.map((o: SupplierOffer) => { const merged = currentOffers.map((o: SupplierOffer) => {
const sup = suppliersRef.current.find(s => s.id === o.supplierId); const sup = suppliersRef.current.find(s => s.id === o.supplierId);
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId); const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
const supSpId = sup?.spId !== undefined ? String(sup.spId) : undefined; // Сверка по SP Id поставщика — только для старых колонок без файла КП: у поставщика с
// несколькими КП она подхватила бы оффер чужой колонки.
const supSpId = sup?.spId !== undefined && sup.kpFileSpId === undefined ? String(sup.spId) : undefined;
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined; const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
const prev = prevOffers.find((p: SupplierOffer) => const prev = prevOffers.find((p: SupplierOffer) =>
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) && (p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
@@ -753,16 +760,22 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const posSpId = extraPosSpIds[o.positionId]; const posSpId = extraPosSpIds[o.positionId];
const existingSup = suppliers.find(s => s.id === o.supplierId); const existingSup = suppliers.find(s => s.id === o.supplierId);
const existingPos = selectedPositions.find(p => p.id === o.positionId); const existingPos = selectedPositions.find(p => p.id === o.positionId);
const supSpIdStr = existingSup?.spId !== undefined ? String(existingSup.spId) : undefined; // Сверка по SP Id поставщика — только для старых колонок без файла КП (см. beforeunload выше).
const supSpIdStr = existingSup?.spId !== undefined && existingSup.kpFileSpId === undefined
? String(existingSup.spId)
: undefined;
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined; const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
const originalOffer = offers.find(orig => const originalOffer = offers.find(orig =>
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) && (orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) && (orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0) (orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
); );
const kpFileOfferId = existingSup // Файл КП колонки известен напрямую; поиск по имени поставщика — только для старых колонок
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId // (у поставщика с несколькими КП он отдал бы обеим колонкам первый файл).
: undefined; const kpFileOfferId = existingSup?.kpFileSpId
?? (existingSup
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
: undefined);
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery; const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined; const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
return { return {
@@ -57,8 +57,9 @@ export type CTStatus =
| "rejected"; | "rejected";
export interface Supplier { export interface Supplier {
id: string; id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer) spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
kpFileSpId?: number; // SP Id файла КП этой колонки — у поставщика может быть несколько КП (напр. KZT и USD)
name: string; name: string;
inn?: string; // UI only — not stored in SP inn?: string; // UI only — not stored in SP
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin' bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
@@ -0,0 +1,48 @@
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './supplierColumns';
import { Supplier } from '../types/domain';
describe('buildSupplierColumnId', () => {
it('колонка = поставщик + файл КП', () => {
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
});
it('без файла (старые данные) — только поставщик', () => {
expect(buildSupplierColumnId(263)).toBe('263');
});
});
describe('findColumnForRecord', () => {
const KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
const USD: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала Саплай»', hasOffer: true };
// Два КП одного поставщика: запись из SP должна попасть в колонку своего файла, а не в первую.
it('находит колонку по поставщику и файлу КП', () => {
expect(findColumnForRecord([KZT, USD], 263, 290)).toBe(USD);
});
it('старая колонка без файла принимает запись поставщика', () => {
expect(findColumnForRecord([LEGACY], 264, 278)).toBe(LEGACY);
});
it('не отдаёт запись чужого файла в колонку другого КП', () => {
expect(findColumnForRecord([KZT], 263, 290)).toBeUndefined();
});
});
describe('buildColumnsFromSpOffers (открытие по ссылке / без сессии)', () => {
const rows = [
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 290, currencyCode: 'USD' },
{ supplierSpId: 264, supplierName: 'ТОО «Кала Саплай»', currencyCode: 'KZT' },
];
it('одна колонка на каждый КП поставщика, со своей валютой', () => {
expect(buildColumnsFromSpOffers(rows)).toEqual([
{ id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'KZT' },
{ id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'USD' },
{ id: '264', spId: 264, kpFileSpId: undefined, name: 'ТОО «Кала Саплай»', hasOffer: true, currency: 'KZT' },
]);
});
});
@@ -0,0 +1,45 @@
import { Supplier } from '../types/domain';
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
// разделителем в ключах ячеек (`${supplierId}::${positionId}`, deliveryKm и т.д.).
export function buildSupplierColumnId(supplierSpId: number, kpFileSpId?: number): string {
return kpFileSpId !== undefined ? `${supplierSpId}_${kpFileSpId}` : String(supplierSpId);
}
// Колонка для записи «Оценка поставщиков»: сначала колонка этого же файла КП, иначе — старая
// колонка поставщика без файла (данные до появления нескольких КП у поставщика).
export function findColumnForRecord(
columns: Supplier[],
supplierSpId: number,
offerFileSpId?: number,
): Supplier | undefined {
const sameFile = columns.find(c => c.spId === supplierSpId && c.kpFileSpId !== undefined && c.kpFileSpId === offerFileSpId);
return sameFile ?? columns.find(c => c.spId === supplierSpId && c.kpFileSpId === undefined);
}
// Запись «Оценка поставщиков», из которой восстанавливается колонка (открытие по ссылке / без сессии).
export interface SpOfferColumnSource {
supplierSpId: number;
supplierName: string;
offerFileSpId?: number;
currencyCode: string;
}
// Одна колонка на каждую пару (поставщик, файл КП), в порядке первого появления.
export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Supplier[] {
const columnsById = new Map<string, Supplier>();
records.forEach(r => {
const id = buildSupplierColumnId(r.supplierSpId, r.offerFileSpId);
if (columnsById.has(id)) return;
columnsById.set(id, {
id,
spId: r.supplierSpId,
kpFileSpId: r.offerFileSpId,
name: r.supplierName,
hasOffer: true,
currency: r.currencyCode,
});
});
return Array.from(columnsById.values());
}
@@ -854,13 +854,14 @@ export class CtService {
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
// По одному элементу на файл КП, а не на поставщика: у поставщика может быть несколько КП.
const seen = new Set<number>(); const seen = new Set<number>();
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = []; const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
for (const item of data.value ?? []) { for (const item of data.value ?? []) {
const supplierSpId = Number(item["supplierId"]); const supplierSpId = Number(item["supplierId"]);
const offerSpId = Number(item["offerFileId"] ?? 0); const offerSpId = Number(item["offerFileId"] ?? 0);
if (!offerSpId || !supplierSpId || seen.has(supplierSpId)) continue; if (!offerSpId || !supplierSpId || seen.has(offerSpId)) continue;
seen.add(supplierSpId); seen.add(offerSpId);
pairs.push({ supplierSpId, offerSpId }); pairs.push({ supplierSpId, offerSpId });
} }
if (pairs.length === 0) return []; if (pairs.length === 0) return [];
@@ -1541,13 +1542,14 @@ export class CtService {
supplierPosition: string; supplierPosition: string;
unit: string; unit: string;
itemIndex: number; itemIndex: number;
offerFileSpId?: number;
}> }>
> { > {
if (positionSpIds.length === 0) return []; if (positionSpIds.length === 0) return [];
const filter = positionSpIds const filter = positionSpIds
.map((id) => `positionId eq ${id}`) .map((id) => `positionId eq ${id}`)
.join(" or "); .join(" or ");
const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit&$filter=${filter}`; const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit,offerFileId&$filter=${filter}`;
const res = await this.http.get(url, SPHttpClient.configurations.v1); const res = await this.http.get(url, SPHttpClient.configurations.v1);
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json(); const data = await res.json();
@@ -1560,6 +1562,7 @@ export class CtService {
supplierPosition: String(item.supplierPosition ?? ""), supplierPosition: String(item.supplierPosition ?? ""),
unit: String(item.unit ?? ""), unit: String(item.unit ?? ""),
itemIndex: parseOfferItemIndex(String(item.Title ?? "")), itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
})); }));
return keepLatestOfferRecords(records); return keepLatestOfferRecords(records);
} }
@@ -1611,6 +1614,7 @@ export class CtService {
id: created.Id, id: created.Id,
supplierSpId: params.supplierSpId, supplierSpId: params.supplierSpId,
positionSpId: params.positionSpId, positionSpId: params.positionSpId,
offerFileSpId: params.offerSpId,
}); });
} }
@@ -1618,9 +1622,10 @@ export class CtService {
id: number; id: number;
supplierSpId: number; supplierSpId: number;
positionSpId: number; positionSpId: number;
offerFileSpId: number;
}): Promise<void> { }): Promise<void> {
const url = const url =
`${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId` + `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,offerFileId` +
`&$filter=supplierId eq ${saved.supplierSpId} and positionId eq ${saved.positionSpId}`; `&$filter=supplierId eq ${saved.supplierSpId} and positionId eq ${saved.positionSpId}`;
const res = await this.http.get(url, SPHttpClient.configurations.v1); const res = await this.http.get(url, SPHttpClient.configurations.v1);
// Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords), // Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords),
@@ -1635,6 +1640,7 @@ export class CtService {
supplierSpId: Number(item.supplierId), supplierSpId: Number(item.supplierId),
positionSpId: Number(item.positionId), positionSpId: Number(item.positionId),
itemIndex: parseOfferItemIndex(String(item.Title ?? "")), itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
})); }));
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers. // MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => { await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => {
@@ -117,3 +117,35 @@ describe('findSupplierOfferIds', () => {
expect(findSupplierOfferIds(records, 18, [121])).toEqual([]); expect(findSupplierOfferIds(records, 18, [121])).toEqual([]);
}); });
}); });
describe('несколько КП одного поставщика (offerFileSpId)', () => {
// Отдельные предложения поставщика (KZT и USD) — разные файлы КП, их записи не дубли друг друга.
it('keepLatestOfferRecords не схлопывает записи разных файлов КП', () => {
const records = [
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
];
expect(keepLatestOfferRecords(records)).toHaveLength(2);
});
it('keepLatestOfferRecords схлопывает дубли внутри одного файла', () => {
const records = [
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
];
expect(keepLatestOfferRecords(records).map(r => r.id)).toEqual([11]);
});
it('findSupersededOfferIds не трогает записи другого КП того же поставщика', () => {
const saved = makeRecord({ id: 20, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 });
const existing = [
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
makeRecord({ id: 15, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
saved,
];
expect(findSupersededOfferIds(existing, saved)).toEqual([15]);
});
});
@@ -1,5 +1,6 @@
// Записи списка «Оценка поставщиков»: одна запись = одна строка КП поставщика по позиции. // Записи списка «Оценка поставщиков»: одна запись = одна строка КП поставщика по позиции.
// У позиции может быть несколько строк КП (комплект) — они различаются itemIndex. // У позиции может быть несколько строк КП (комплект) — они различаются itemIndex. У поставщика
// может быть несколько КП (отдельные предложения, напр. KZT и USD) — они различаются offerFileSpId.
export interface IOfferRecordKey { export interface IOfferRecordKey {
// SP Id записи — чем больше, тем позже создана. // SP Id записи — чем больше, тем позже создана.
@@ -7,6 +8,8 @@ export interface IOfferRecordKey {
supplierSpId: number; supplierSpId: number;
positionSpId: number; positionSpId: number;
itemIndex: number; itemIndex: number;
// SP Id файла КП. Пусто у старых записей и в формах, где несколько КП у поставщика не поддержаны.
offerFileSpId?: number;
} }
// Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N). // Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N).
@@ -16,13 +19,13 @@ export function parseOfferItemIndex(title: string | undefined): number {
return parseInt(parts[2], 10) || 0; return parseInt(parts[2], 10) || 0;
} }
// Одна запись на (поставщик, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся, // Одна запись на (поставщик, файл КП, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
// когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только // когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только
// при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками. // при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками.
export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]): T[] { export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]): T[] {
const latestByKey = new Map<string, T>(); const latestByKey = new Map<string, T>();
for (const record of records) { for (const record of records) {
const key = `${record.supplierSpId}::${record.positionSpId}::${record.itemIndex}`; const key = `${record.supplierSpId}::${record.offerFileSpId ?? ''}::${record.positionSpId}::${record.itemIndex}`;
const previous = latestByKey.get(key); const previous = latestByKey.get(key);
if (!previous || record.id > previous.id) { if (!previous || record.id > previous.id) {
latestByKey.set(key, record); latestByKey.set(key, record);
@@ -43,16 +46,17 @@ export function findSupplierOfferIds(
.map(record => record.id); .map(record => record.id);
} }
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика по той же позиции, // Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
// включая строки комплекта — новый КП поставщика заменяет его прежнее предложение целиком. // по той же позиции, включая строки комплекта. Записи другого КП того же поставщика — отдельное
// Записи новее сохранённой не трогаем: их могла создать параллельная обработка. // предложение, их не трогаем. Записи новее сохранённой тоже: их могла создать параллельная обработка.
export function findSupersededOfferIds( export function findSupersededOfferIds(
existing: IOfferRecordKey[], existing: IOfferRecordKey[],
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId'>, saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId' | 'offerFileSpId'>,
): number[] { ): number[] {
return existing return existing
.filter(record => .filter(record =>
record.supplierSpId === saved.supplierSpId && record.supplierSpId === saved.supplierSpId &&
record.offerFileSpId === saved.offerFileSpId &&
record.positionSpId === saved.positionSpId && record.positionSpId === saved.positionSpId &&
record.id < saved.id record.id < saved.id
) )