fix(ct): предложения из КП пишутся в SP сразу при обработке, а не только по «Сохранить»

Предложения писались внутри цикла по позициям и только если поставщика уже определил Syntex.
Syntex отключён, поставщика теперь называет поток — узнаём его после разбора всех позиций,
поэтому при обработке КП в «Оценка поставщиков» не попадало ничего: закрыл вкладку без
«Сохранить» — и разобранные цены пропали. Теперь записи строятся после определения поставщика
(buildKpOffersToSave), а сбои потока и записи идут в консоль вместо пустого catch.
This commit is contained in:
Тимур Городецкий
2026-10-01 10:37:54 +05:00
parent 2316f8bd6d
commit d6d92ece3f
3 changed files with 133 additions and 14 deletions
@@ -0,0 +1,74 @@
import { buildKpOffersToSave } from './kpOfferRecords';
import { FlowPositionResult } from './kpTypes';
import { Position } from '../types/domain';
function makePosition(overrides: Partial<Position>): Position {
return {
id: 'pos_1',
spId: 1543,
requestId: 'req_1',
requestNumber: '051/26-08/0015',
materialName: 'Доска пробковая',
disciplineCode: '',
smrType: '',
unit: 'шт',
agskCode: '',
uniqueCode: '',
quantity: 4,
positionType: 'ТМЦ',
deadline: '',
...overrides,
};
}
const POSITIONS = [
makePosition({}),
makePosition({ id: 'pos_2', spId: 1544, materialName: 'Флипчарт', quantity: 3 }),
];
const RESULTS: FlowPositionResult[] = [
{ positionId: 'pos_1', positionSpId: 1543, flowResult: { position: 'Доска в раме', unit: 'шт.', quantity: 4, price: 28500, cost: 114000 } },
{ positionId: 'pos_2', positionSpId: 1544, flowResult: { price: 98000, cost: 294000 } },
];
describe('buildKpOffersToSave', () => {
// Syntex отключён: поставщика называет поток, и узнаём его только после разбора всех позиций.
// Раньше записи строились внутри цикла и без поставщика от Syntex не строились вовсе.
it('строит записи по поставщику, определённому после разбора', () => {
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
expect(offers).toHaveLength(2);
expect(offers[0]).toEqual({
positionSpId: 1543,
supplierSpId: 264,
offerSpId: 283,
supplierPosition: 'Доска в раме',
unit: 'шт.',
quantity: 4,
price: 28500,
summa: 114000,
});
});
it('пустые поля потока берёт из позиции СТ', () => {
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
expect(offers[1]).toMatchObject({ supplierPosition: 'Флипчарт', unit: 'шт', quantity: 3, price: 98000 });
});
it('без поставщика записей нет', () => {
expect(buildKpOffersToSave(RESULTS, POSITIONS, undefined, 283)).toEqual([]);
});
it('пропускает позицию без SP Id', () => {
const results: FlowPositionResult[] = [{ positionId: 'pos_x', flowResult: { price: 1 } }];
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)).toEqual([]);
});
it('берёт SP Id из позиции, если в результате его нет', () => {
const results: FlowPositionResult[] = [{ positionId: 'pos_2', flowResult: { price: 5 } }];
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)[0].positionSpId).toBe(1544);
});
});
@@ -0,0 +1,45 @@
import { FlowPositionResult } from './kpTypes';
import { Position } from '../types/domain';
// Запись «Оценка поставщиков» по одной позиции из разобранного КП (вход CtService.saveKpOffer).
export interface KpOfferToSave {
positionSpId: number;
supplierSpId: number;
offerSpId: number;
supplierPosition: string;
unit: string;
quantity: number;
price: number;
summa: number;
}
// Строит записи после разбора всего КП. Поставщик может прийти из ответа потока (Syntex отключён),
// а его узнаём только когда разобраны все позиции — поэтому записи не строим внутри цикла по позициям.
// Пустые поля потока добираем из позиции СТ.
export function buildKpOffersToSave(
results: FlowPositionResult[],
positions: Position[],
supplierSpId: number | undefined,
fileSpId: number,
): KpOfferToSave[] {
if (!supplierSpId) return [];
const offers: KpOfferToSave[] = [];
for (const { positionId, positionSpId, flowResult } of results) {
const position = positions.find(p => p.id === positionId);
const spId = positionSpId ?? position?.spId;
if (!spId) continue;
offers.push({
positionSpId: spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: flowResult.position ?? position?.materialName ?? '',
unit: flowResult.unit ?? position?.unit ?? '',
quantity: flowResult.quantity ?? position?.quantity ?? 0,
price: flowResult.price ?? 0,
summa: flowResult.cost ?? 0,
});
}
return offers;
}
@@ -3,6 +3,7 @@ import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
import { buildKpSuppliersAndOffers } from './step2Persist';
import { buildKpOffersToSave } from './kpOfferRecords';
let localIdCounter = 0;
@@ -91,20 +92,9 @@ export function useKpProcessing(
try {
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
if (pos.spId && supplierSpId && result) {
await service.saveKpOffer({
positionSpId: pos.spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: result.position ?? pos.materialName,
unit: result.unit ?? pos.unit,
quantity: result.quantity ?? pos.quantity,
price: result.price ?? 0,
summa: result.cost ?? 0,
});
}
} catch {
// individual position failure doesn't stop others
} catch (err) {
// Сбой одной позиции не останавливает остальные, но и не теряется молча.
console.warn(`[КП] поток не разобрал позицию «${pos.materialName}»:`, err);
}
if (mountedRef.current) {
setKpFiles(prev => prev.map(f =>
@@ -128,6 +118,16 @@ export function useKpProcessing(
}
}
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
await Promise.all(offersToSave.map(offer =>
service.saveKpOffer(offer).catch(err => {
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиции целиком.
console.warn(`[КП] не удалось записать предложение по позиции ${offer.positionSpId}:`, err);
})
));
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
if (supplierBIN && resolvedSupplierSpId) {