fix(kps): повторно загруженный КП поставщика не даёт вторую колонку в таблице сравнения

Сборщики КПС строили поставщика, предложения и критерии по каждому файлу КП, поэтому тот же
КП под другим именем (или исправленный) давал в таблице вторую колонку того же поставщика
и задвоенные строки позиций. Теперь, как в СТ (5f5f0e7), из нескольких КП одного поставщика
берётся последний. Отбор вынесен в shared/offers/kpFiles и общий для СТ и КПС; дедуп офферов
при загрузке КПС переведён на общий keepLatestOfferRecords (поведение то же).

Заодно в репозиторий попадают тесты step2Persist.test.ts.
This commit is contained in:
Тимур Городецкий
2026-10-01 11:02:45 +05:00
parent 2bf50884c0
commit 9490d5b83f
6 changed files with 219 additions and 33 deletions
@@ -0,0 +1,128 @@
import { buildKpSuppliersAndOffers } from './step2Persist';
import { KpFile } from './kpTypes';
import { CurrencyRate } from '../types/domain';
const USD_RATE: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 500 };
// Минимальный файл КП: обработан потоком, одна позиция. Нужные поля переопределяются в тесте.
function makeKpFile(overrides: Partial<KpFile>): KpFile {
return {
localId: 'local_1',
file: new File([''], 'kp.pdf'),
status: 'done',
spId: 100,
supplierName: 'ТОО Поставщик',
supplierSpId: 10,
flowDone: 1,
flowTotal: 1,
currencyCode: 'KZT',
flowResults: [
{ positionId: 'pos_1', flowResult: { position: 'Кабель', unit: 'м', price: 200, quantity: 5 } },
],
...overrides,
};
}
describe('buildKpSuppliersAndOffers', () => {
it('строит поставщика и оффер с суммой в тенге', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
expect(suppliers).toEqual([
{ id: '10', spId: 10, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
]);
expect(offers).toHaveLength(1);
expect(offers[0]).toMatchObject({
supplierId: '10',
positionId: 'pos_1',
supplierPosition: 'Кабель',
pricePerUnit: 200,
quantity: 5,
pricePerUnitKzt: 200,
totalPriceKzt: 1000,
offerId: '100',
});
});
// Регресс 5f5f0e7: два КП одного поставщика давали дубль колонки и задвоенные строки позиций.
it('из нескольких КП одного поставщика берёт только последний', () => {
const first = makeKpFile({ localId: 'a', spId: 100 });
const second = makeKpFile({
localId: 'b',
spId: 101,
flowResults: [{ positionId: 'pos_1', flowResult: { price: 300, quantity: 5 } }],
});
const { suppliers, offers } = buildKpSuppliersAndOffers([first, second], []);
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');
});
it('разных поставщиков оставляет отдельными колонками', () => {
const first = makeKpFile({ localId: 'a', supplierSpId: 10, supplierName: 'ТОО Альфа' });
const second = makeKpFile({ localId: 'b', supplierSpId: 20, supplierName: 'ТОО Бета' });
const { suppliers } = buildKpSuppliersAndOffers([first, second], []);
expect(suppliers.map(s => s.id)).toEqual(['10', '20']);
});
it('пропускает файл, у которого поток ещё не определил поставщика', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({ supplierName: undefined })], []);
expect(suppliers).toEqual([]);
expect(offers).toEqual([]);
});
it('поставщик без записи в SP получает локальный id и не даёт офферов', () => {
const file = makeKpFile({ localId: 'local_7', supplierSpId: undefined });
const { suppliers, offers } = buildKpSuppliersAndOffers([file], []);
expect(suppliers[0].id).toBe('local_7');
expect(offers).toEqual([]);
});
it('пересчитывает валютный КП в тенге по курсу', () => {
const file = makeKpFile({ currencyCode: 'USD' });
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
expect(offers[0]).toMatchObject({
currencyCode: 'USD',
pricePerUnit: 200,
pricePerUnitKzt: 100000,
totalPriceKzt: 500000,
});
});
it('без курса валюты берёт курс 1', () => {
const file = makeKpFile({ currencyCode: 'EUR' });
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
expect(offers[0].pricePerUnitKzt).toBe(200);
});
});
@@ -2,6 +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';
export interface PersistStep2Args {
service: CtService;
@@ -11,23 +12,6 @@ export interface PersistStep2Args {
noOfferSuppliers: NoOfferEntry[];
}
// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают
// один и тот же supplierSpId — saveSupplierWithOffer находит поставщика по имени. Без отбора
// в таблице сравнения появляется дубль колонки с тем же id, а офферы обоих файлов складываются
// в одну ячейку (задвоенные строки позиций). Оставляем последний загруженный файл поставщика,
// но обработанный файл не вытесняем тем, что ещё в процессе.
function pickLatestFilePerSupplier(kpFiles: KpFile[]): KpFile[] {
const fileBySupplier = new Map<string, KpFile>();
kpFiles.forEach(f => {
if (!f.supplierName) return;
const supplierKey = f.supplierSpId !== undefined ? String(f.supplierSpId) : f.supplierName;
const existing = fileBySupplier.get(supplierKey);
const canReplace = !existing || f.status === 'done' || existing.status !== 'done';
if (canReplace) fileBySupplier.set(supplierKey, f);
});
return Array.from(fileBySupplier.values());
}
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
@@ -35,7 +19,7 @@ export function buildKpSuppliersAndOffers(
allKpFiles: KpFile[],
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const kpFiles = pickLatestFilePerSupplier(allKpFiles);
const kpFiles = pickLatestKpFilePerSupplier(allKpFiles);
const suppliers: Supplier[] = kpFiles
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
@@ -0,0 +1,56 @@
import { buildKpCriteria, buildKpOffers, buildKpSuppliers } from './kpOffers';
import { KpFile } from './kpTypes';
function makeKpFile(overrides: Partial<KpFile>): KpFile {
return {
localId: 'kp_1',
file: new File([''], 'kp.pdf'),
status: 'done',
spId: 100,
supplierName: 'ТОО «Кала Саплай»',
supplierSpId: 264,
currencyCode: 'KZT',
flowDone: 1,
flowTotal: 1,
flowResults: [
{ positionId: 'line_1', flowResult: { position: 'Доска пробковая', price: 28500, quantity: 4, conditions: 'Предоплата' } },
],
...overrides,
};
}
// Тот же КП поставщика загружен повторно под другим именем (или прислан исправленный).
const FIRST = makeKpFile({ localId: 'kp_1', spId: 100 });
const SECOND = makeKpFile({
localId: 'kp_2',
spId: 101,
flowResults: [
{ positionId: 'line_1', flowResult: { position: 'Доска пробковая', price: 27000, quantity: 4, conditions: 'Постоплата' } },
],
});
describe('КП одного поставщика: берётся последний', () => {
it('одна колонка поставщика вместо двух', () => {
expect(buildKpSuppliers([FIRST, SECOND])).toHaveLength(1);
});
it('предложения только из последнего КП', () => {
const offers = buildKpOffers([FIRST, SECOND]);
expect(offers).toHaveLength(1);
expect(offers[0]).toMatchObject({ pricePerUnit: 27000, offerId: '101' });
});
it('критерии из последнего КП', () => {
const criteria = buildKpCriteria([FIRST, SECOND]);
expect(criteria).toHaveLength(1);
expect(criteria[0].paymentType).toBe('Постоплата');
});
it('разных поставщиков оставляет отдельными колонками', () => {
const other = makeKpFile({ localId: 'kp_3', supplierName: 'ТОО «Арлан Офис Снаб»', supplierSpId: 263 });
expect(buildKpSuppliers([FIRST, other]).map(s => s.id)).toEqual(['264', '263']);
});
});
@@ -1,11 +1,14 @@
import { CurrencyRate, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
import { KpFile } from './kpTypes';
import { pickLatestKpFilePerSupplier } from '../../../../shared/offers/kpFiles';
// Чистые сборщики доменных сущностей из обработанных файлов КП (без React, без сети).
// Из нескольких КП одного поставщика каждый сборщик берёт последний (pickLatestKpFilePerSupplier):
// иначе повторно загруженный КП давал вторую колонку того же поставщика.
// Поставщики с КП — по одному на файл с распознанным поставщиком. id = String(spId) для связи с офферами.
export function buildKpSuppliers(files: KpFile[]): Supplier[] {
return files
return pickLatestKpFilePerSupplier(files)
.filter((f) => f.supplierName)
.map((f) => ({
id: f.supplierSpId !== undefined ? String(f.supplierSpId) : f.localId,
@@ -19,7 +22,7 @@ export function buildKpSuppliers(files: KpFile[]): Supplier[] {
// Офферы из результатов потока (позиция × поставщик). Цена/кол-во — в валюте поставщика.
export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
const offers: SupplierOffer[] = [];
for (const f of files) {
for (const f of pickLatestKpFilePerSupplier(files)) {
if (f.supplierSpId === undefined || f.flowResults.length === 0) {
continue;
}
@@ -43,7 +46,7 @@ export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
// Критерии из результатов потока — один набор на поставщика (первый файл с любым критерием).
export function buildKpCriteria(files: KpFile[]): SupplierCriteria[] {
const criteria: SupplierCriteria[] = [];
for (const f of files) {
for (const f of pickLatestKpFilePerSupplier(files)) {
if (f.supplierSpId === undefined) {
continue;
}
@@ -10,6 +10,7 @@ import {
IApproverToWrite,
IMatrixRow
} from '../../../shared/approval';
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
import type {
CtFormData,
CtStatus,
@@ -718,14 +719,12 @@ export class KpsCtFormService {
`&$filter=${filter}&$top=2000`
);
const mapped = rows.map((r) => {
const parts = String(r.Title ?? '').split('-');
const itemIndex = parts.length >= 3 ? parseInt(parts[2], 10) || 0 : 0;
return {
id: r.Id,
positionSpId: Number(r.positionId ?? 0),
supplierSpId: Number(r.supplierId ?? r.supplier?.Id ?? 0),
supplierName: String(r.supplier?.Title ?? ''),
itemIndex,
itemIndex: parseOfferItemIndex(r.Title),
supplierPosition: String(r.supplierPosition ?? ''),
unit: String(r.unit ?? ''),
quantity: Number(r.quantity ?? 0),
@@ -737,15 +736,7 @@ export class KpsCtFormService {
// Дедуп: одна запись на (поставщик, позиция, itemIndex), держим последнюю (max Id). На «защищённом»
// списке recycle прежних офферов мог не сработать (`saveOffers` ловит ошибку) → накапливаются дубли
// прошлых сохранений; без дедупа они рисуются как повторяющиеся под-строки при реконструкции.
const byKey = new Map<string, (typeof mapped)[0]>();
for (const m of mapped) {
const key = `${m.supplierSpId}::${m.positionSpId}::${m.itemIndex}`;
const prev = byKey.get(key);
if (!prev || m.id > prev.id) {
byKey.set(key, m);
}
}
return Array.from(byKey.values());
return keepLatestOfferRecords(mapped);
}
// Следующий номер СТ — сквозная нумерация max(number)+1 (одним POST, без MERGE).
@@ -0,0 +1,24 @@
// Файл КП в обработке — общее подмножество полей KpFile форм СТ и КПС.
export interface IKpFileForSupplier {
supplierName?: string;
supplierSpId?: number;
status: string;
}
const DONE_STATUS = 'done';
// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают
// один и тот же supplierSpId — поставщик находится по имени. Без отбора в таблице сравнения
// появляется дубль колонки с тем же id, а офферы обоих файлов складываются в одну ячейку.
// Оставляем последний загруженный файл поставщика, но обработанный не вытесняем тем, что ещё в процессе.
export function pickLatestKpFilePerSupplier<T extends IKpFileForSupplier>(kpFiles: T[]): T[] {
const fileBySupplier = new Map<string, T>();
kpFiles.forEach(file => {
if (!file.supplierName) return;
const supplierKey = file.supplierSpId !== undefined ? String(file.supplierSpId) : file.supplierName;
const existing = fileBySupplier.get(supplierKey);
const canReplace = !existing || file.status === DONE_STATUS || existing.status !== DONE_STATUS;
if (canReplace) fileBySupplier.set(supplierKey, file);
});
return Array.from(fileBySupplier.values());
}