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 { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import styles from './CtCreateForm.module.scss';
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 spCurrencyRates = loadedRates;
// Build unique suppliers from offers (used only as fallback when session has none)
const supplierCurrencyMap = new Map<number, string>();
rawOffers.forEach(o => {
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
});
// Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько
// КП в разных валютах. Используются, только когда в сессии колонок нет.
const currencyCodeOf = (valutaSpId?: number): string =>
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>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
id: String(spId), spId, name, hasOffer: true as const,
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
}));
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
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
// (`${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 => {
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> = {};
rawOffers.forEach(o => {
if (o.deliveryDistanceKm !== undefined) {
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;
}
});
// Build offers in domain format, restoring offerId from session
// Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
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';
return {
id: '',
supplierId: String(o.supplierSpId),
supplierId: columnIdOf(o.supplierSpId, o.offerFileSpId),
positionId: String(o.positionSpId),
itemIndex: o.itemIndex,
unit: o.unit,
@@ -75,18 +75,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
// Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах.
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
if (!uploaded?.supplierName) return;
onSave(applySupplierCurrency(
formData.suppliers ?? [],
formData.offers ?? [],
uploaded.supplierName,
uploaded,
currencyCode,
currencyRates,
));
kp.kpFiles
.filter(f => f.supplierName === uploaded.supplierName)
.filter(f => f.spId === fileSpId)
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
};
@@ -152,7 +153,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
files={formData.uploadedKpFiles}
service={service}
currencyOptions={currencyOptions}
getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)}
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
onCurrencyChange={handleUploadedCurrencyChange}
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
disabled={isNotificationLink || isReadOnly}
@@ -2,13 +2,14 @@ import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFileRef } from './kpCurrency';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
currencyOptions: string[];
getCurrency: (supplierName: string | undefined) => string;
getCurrency: (file: KpFileRef) => string;
onCurrencyChange: (spId: number, currencyCode: string) => void;
onRemove: (spId: number) => void;
disabled?: boolean;
@@ -40,12 +41,12 @@ export const UploadedKpList: React.FC<Props> = ({
{f.name}
</a>
{disabled ? (
<span className={styles.kpCurrencyCode}>{getCurrency(f.supplierName)}</span>
<span className={styles.kpCurrencyCode}>{getCurrency(f)}</span>
) : (
<>
<select
className={styles.kpCurrencySelect}
value={getCurrency(f.supplierName)}
value={getCurrency(f)}
onChange={e => onCurrencyChange(f.spId, e.target.value)}
// Валюта хранится на поставщике — без него менять нечего.
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';
// Валюта хранится на поставщике (Шаг 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) видели одну и ту же валюту.
export function applySupplierCurrency(
suppliers: Supplier[],
offers: SupplierOffer[],
supplierName: string,
file: KpFileRef,
currencyCode: string,
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const supplierIds = new Set(
suppliers.filter(s => s.hasOffer && s.name === supplierName).map(s => s.id),
);
const columnIds = new Set(findFileColumns(suppliers, file).map(s => s.id));
const rate = currencyCode === BASE_CURRENCY
? 1
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
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 => {
if (!supplierIds.has(o.supplierId)) return o;
if (!columnIds.has(o.supplierId)) return o;
const pricePerUnitKzt = o.pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * o.quantity;
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
@@ -32,7 +44,6 @@ export function applySupplierCurrency(
return { suppliers: nextSuppliers, offers: nextOffers };
}
export function getSupplierCurrency(suppliers: Supplier[], supplierName: string | undefined): string {
const supplier = suppliers.find(s => s.hasOffer && s.name === supplierName);
return supplier?.currency ?? BASE_CURRENCY;
export function getSupplierCurrency(suppliers: Supplier[], file: KpFileRef): string {
return findFileColumns(suppliers, file)[0]?.currency ?? BASE_CURRENCY;
}
@@ -28,11 +28,11 @@ describe('buildKpSuppliersAndOffers', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
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[0]).toMatchObject({
supplierId: '10',
supplierId: '10_100',
positionId: 'pos_1',
supplierPosition: 'Кабель',
pricePerUnit: 200,
@@ -43,41 +43,27 @@ describe('buildKpSuppliersAndOffers', () => {
});
});
// Регресс 5f5f0e7: два КП одного поставщика давали дубль колонки и задвоенные строки позиций.
it('из нескольких КП одного поставщика берёт только последний', () => {
const first = makeKpFile({ localId: 'a', spId: 100 });
const second = makeKpFile({
// Два КП одного поставщика (напр. KZT и USD) — два отдельных предложения: по колонке на файл.
// Раньше брался только последний файл (5f5f0e7), и второе предложение пропадало.
it('каждый КП поставщика — отдельная колонка со своими предложениями', () => {
const kzt = makeKpFile({ localId: 'a', spId: 100 });
const usd = makeKpFile({
localId: 'b',
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(offers).toHaveLength(1);
expect(offers[0].offerId).toBe('101');
expect(offers[0].pricePerUnit).toBe(300);
});
it('не вытесняет обработанный КП тем, что ещё в процессе', () => {
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');
expect(suppliers.map(s => ({ id: s.id, kpFileSpId: s.kpFileSpId, currency: s.currency }))).toEqual([
{ id: '10_100', kpFileSpId: 100, currency: 'KZT' },
{ id: '10_101', kpFileSpId: 101, currency: 'USD' },
]);
expect(offers.map(o => ({ supplierId: o.supplierId, offerId: o.offerId, pricePerUnit: o.pricePerUnit }))).toEqual([
{ supplierId: '10_100', offerId: '100', pricePerUnit: 200 },
{ supplierId: '10_101', offerId: '101', pricePerUnit: 3 },
]);
});
it('разных поставщиков оставляет отдельными колонками', () => {
@@ -86,7 +72,7 @@ describe('buildKpSuppliersAndOffers', () => {
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('пропускает файл, у которого поток ещё не определил поставщика', () => {
@@ -2,7 +2,7 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes';
import { pickLatestKpFilePerSupplier } from '../../../../shared/offers/kpFiles';
import { buildSupplierColumnId } from '../utils/supplierColumns';
export interface PersistStep2Args {
service: CtService;
@@ -12,18 +12,23 @@ export interface PersistStep2Args {
noOfferSuppliers: NoOfferEntry[];
}
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
// Строит колонки поставщиков и их офферы из результатов потока. Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
// Колонка — на каждый файл КП: несколько КП одного поставщика (напр. KZT и USD) — отдельные
// предложения. Раньше брался только последний файл поставщика, и второе предложение пропадало.
export function buildKpSuppliersAndOffers(
allKpFiles: KpFile[],
currencyRates: CurrencyRate[],
): { 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
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
id: columnIdOf(f),
spId: f.supplierSpId,
kpFileSpId: f.spId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
@@ -43,7 +48,7 @@ export function buildKpSuppliersAndOffers(
const totalPriceKzt = pricePerUnitKzt * quantity;
offers.push({
id: '',
supplierId: String(f.supplierSpId),
supplierId: columnIdOf(f),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
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
// before proceeding to Step 3.
if (newOffers.length > 0) {
// id колонки («поставщик_файл») в SP не пишем — туда идут SP Id поставщика и позиции.
const offersForSP = newOffers
.map(o => {
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) {
await service.saveOffers(offersForSP, savedRates);
}
}
// Мержим с поставщиками из предыдущих сессий на шаге 2
// (повторная загрузка КП того же поставщика — заменяем по имени)
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
// Мержим с колонками из предыдущих сессий на шаге 2 — по id колонки (поставщик + файл КП):
// другой КП того же поставщика остаётся отдельной колонкой.
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
const mergedSuppliersWithOffer: Supplier[] = [
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierIds.has(s.id)),
...newSuppliersWithOffer,
];
const mergedOffers: SupplierOffer[] = [
@@ -56,8 +56,9 @@ export function useKpProcessing(
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
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];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
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 { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService';
import { findColumnForRecord } from '../utils/supplierColumns';
let extraIdCounter = 0;
@@ -138,9 +139,13 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
service.loadCriteria(supplierSpIds)
.then(rawCriteria => {
const spMap: Record<string, CriteriaEntry> = {};
// Критерии в SP — на поставщика, а не на КП: у всех колонок поставщика (несколько КП) они общие.
rawCriteria.forEach(c => {
const sup = suppliers.find(s => s.spId === c.supplierSpId);
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
suppliers
.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
setCriteriaMap(prev => ({ ...spMap, ...prev }));
@@ -165,7 +170,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const newEdits: EditableOffers = {};
rawOffers.forEach(raw => {
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;
const key = buildOfferKey(sup.id, pos.id);
const item: CellEdit = {
@@ -221,7 +226,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const merged = currentOffers.map((o: SupplierOffer) => {
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
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 prev = prevOffers.find((p: SupplierOffer) =>
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
@@ -753,16 +760,22 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const posSpId = extraPosSpIds[o.positionId];
const existingSup = suppliers.find(s => s.id === o.supplierId);
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 originalOffer = offers.find(orig =>
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
(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 kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
return {
@@ -57,8 +57,9 @@ export type CTStatus =
| "rejected";
export interface Supplier {
id: string;
id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
kpFileSpId?: number; // SP Id файла КП этой колонки — у поставщика может быть несколько КП (напр. KZT и USD)
name: string;
inn?: string; // UI only — not stored in SP
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 [];
const data = await res.json();
// По одному элементу на файл КП, а не на поставщика: у поставщика может быть несколько КП.
const seen = new Set<number>();
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
for (const item of data.value ?? []) {
const supplierSpId = Number(item["supplierId"]);
const offerSpId = Number(item["offerFileId"] ?? 0);
if (!offerSpId || !supplierSpId || seen.has(supplierSpId)) continue;
seen.add(supplierSpId);
if (!offerSpId || !supplierSpId || seen.has(offerSpId)) continue;
seen.add(offerSpId);
pairs.push({ supplierSpId, offerSpId });
}
if (pairs.length === 0) return [];
@@ -1541,13 +1542,14 @@ export class CtService {
supplierPosition: string;
unit: string;
itemIndex: number;
offerFileSpId?: number;
}>
> {
if (positionSpIds.length === 0) return [];
const filter = positionSpIds
.map((id) => `positionId eq ${id}`)
.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);
if (!res.ok) return [];
const data = await res.json();
@@ -1560,6 +1562,7 @@ export class CtService {
supplierPosition: String(item.supplierPosition ?? ""),
unit: String(item.unit ?? ""),
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
}));
return keepLatestOfferRecords(records);
}
@@ -1611,6 +1614,7 @@ export class CtService {
id: created.Id,
supplierSpId: params.supplierSpId,
positionSpId: params.positionSpId,
offerFileSpId: params.offerSpId,
});
}
@@ -1618,9 +1622,10 @@ export class CtService {
id: number;
supplierSpId: number;
positionSpId: number;
offerFileSpId: number;
}): Promise<void> {
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}`;
const res = await this.http.get(url, SPHttpClient.configurations.v1);
// Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords),
@@ -1635,6 +1640,7 @@ export class CtService {
supplierSpId: Number(item.supplierId),
positionSpId: Number(item.positionId),
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
}));
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => {
@@ -117,3 +117,35 @@ describe('findSupplierOfferIds', () => {
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 {
// SP Id записи — чем больше, тем позже создана.
@@ -7,6 +8,8 @@ export interface IOfferRecordKey {
supplierSpId: number;
positionSpId: number;
itemIndex: number;
// SP Id файла КП. Пусто у старых записей и в формах, где несколько КП у поставщика не поддержаны.
offerFileSpId?: number;
}
// Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N).
@@ -16,13 +19,13 @@ export function parseOfferItemIndex(title: string | undefined): number {
return parseInt(parts[2], 10) || 0;
}
// Одна запись на (поставщик, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
// Одна запись на (поставщик, файл КП, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
// когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только
// при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками.
export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]): T[] {
const latestByKey = new Map<string, T>();
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);
if (!previous || record.id > previous.id) {
latestByKey.set(key, record);
@@ -43,16 +46,17 @@ export function findSupplierOfferIds(
.map(record => record.id);
}
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика по той же позиции,
// включая строки комплекта — новый КП поставщика заменяет его прежнее предложение целиком.
// Записи новее сохранённой не трогаем: их могла создать параллельная обработка.
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
// по той же позиции, включая строки комплекта. Записи другого КП того же поставщика — отдельное
// предложение, их не трогаем. Записи новее сохранённой тоже: их могла создать параллельная обработка.
export function findSupersededOfferIds(
existing: IOfferRecordKey[],
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId'>,
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId' | 'offerFileSpId'>,
): number[] {
return existing
.filter(record =>
record.supplierSpId === saved.supplierSpId &&
record.offerFileSpId === saved.offerFileSpId &&
record.positionSpId === saved.positionSpId &&
record.id < saved.id
)