fix(kps): повторно загруженный КП поставщика не даёт вторую колонку в таблице сравнения
Сборщики КПС строили поставщика, предложения и критерии по каждому файлу КП, поэтому тот же
КП под другим именем (или исправленный) давал в таблице вторую колонку того же поставщика
и задвоенные строки позиций. Теперь, как в СТ (5f5f0e7), из нескольких КП одного поставщика
берётся последний. Отбор вынесен в shared/offers/kpFiles и общий для СТ и КПС; дедуп офферов
при загрузке КПС переведён на общий keepLatestOfferRecords (поведение то же).
Заодно в репозиторий попадают тесты step2Persist.test.ts.
This commit is contained in:
+128
@@ -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
-18
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user