fix(ct): повторное «Сохранить» на шаге 3 не создаёт доп. строки и поставщиков второй раз

Доп. строки («Логистика», «Доп. расходы») и новые поставщики шага 3 создаются в SP в начале
сохранения, а их SP-id попадали в стейт только в самом конце. Если дальше падало сохранение
офферов, id терялись, и повторное «Сохранить» создавало те же строки ещё раз. Теперь id
запоминаются сразу после создания — и при успехе, и при сбое (applyCreatedIds).

Заодно в репозиторий попадают тесты расчёта сумм matrixHelpers.test.ts.
This commit is contained in:
Тимур Городецкий
2026-10-01 10:56:36 +05:00
parent d6d92ece3f
commit 1899a5ab38
3 changed files with 170 additions and 34 deletions
@@ -0,0 +1,102 @@
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, round2Str } from './matrixHelpers';
import { ExtraPosition } from './matrixTypes';
describe('computeOffer', () => {
it('считает сумму в тенге для КП в тенге', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 150, quantity: 4 },
'KZT',
1,
);
expect(offer).toMatchObject({
supplierId: 's1',
positionId: 'p1',
currencyCode: 'KZT',
pricePerUnit: 150,
quantity: 4,
pricePerUnitKzt: 150,
totalPriceKzt: 600,
totalWithExtraKzt: 600,
});
});
it('пересчитывает валютную цену по курсу', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, quantity: 3 },
'USD',
500,
);
expect(offer.pricePerUnitKzt).toBe(5000);
expect(offer.totalPriceKzt).toBe(15000);
});
it('ручная цена в тенге важнее пересчёта по курсу', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, priceKztOverride: 4800, quantity: 2 },
'USD',
500,
);
expect(offer.pricePerUnitKzt).toBe(4800);
expect(offer.totalPriceKzt).toBe(9600);
});
it('пустая ячейка даёт нули, а не NaN', () => {
const offer = computeOffer({ supplierId: 's1', positionId: 'p1', itemIndex: 0 }, 'KZT', 1);
expect(offer.pricePerUnit).toBe(0);
expect(offer.quantity).toBe(0);
expect(offer.totalPriceKzt).toBe(0);
});
});
describe('buildOfferKey', () => {
it('склеивает поставщика и позицию через ::', () => {
expect(buildOfferKey('s1', 'p1')).toBe('s1::p1');
});
});
describe('getSubCols', () => {
it('для тенге 5 колонок, для валюты 7, в урезанном режиме 3', () => {
expect(getSubCols('KZT')).toBe(5);
expect(getSubCols('USD')).toBe(7);
expect(getSubCols('USD', true)).toBe(3);
});
});
describe('round2Str', () => {
it('отрезает хвост плавающей точки', () => {
expect(round2Str(275620.94999)).toBe('275620.95');
});
});
describe('applyCreatedIds', () => {
// Доп. строку создали в SP, а следующий шаг сохранения упал: id должен остаться в стейте,
// иначе повторное «Сохранить» создаст строку «Логистика» ещё раз.
it('проставляет созданный SP Id', () => {
const extras: ExtraPosition[] = [{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс' }];
expect(applyCreatedIds(extras, { extra_pos_1: { spId: 1601 } })).toEqual([
{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс', spId: 1601 },
]);
});
it('не трогает строки, которых нет среди созданных', () => {
const extras: Array<{ id: string; spId?: number }> = [{ id: 'extra_pos_1', spId: 1500 }, { id: 'extra_pos_2' }];
expect(applyCreatedIds(extras, { extra_pos_2: { spId: 1601 } })).toEqual([
{ id: 'extra_pos_1', spId: 1500 },
{ id: 'extra_pos_2', spId: 1601 },
]);
});
it('не мутирует исходный массив', () => {
const extras = [{ id: 'extra_sup_1' as string, spId: undefined as number | undefined }];
applyCreatedIds(extras, { extra_sup_1: { spId: 7 } });
expect(extras[0].spId).toBeUndefined();
});
});
@@ -73,3 +73,13 @@ export function computeOffer(draft: CellEdit, currencyCode: string, rate: number
export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`;
}
// Проставляет id, полученные при создании в SP (доп. строки / поставщики Шага 3), в элементы стейта.
// Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить»
// увидит spId и не создаст ту же строку ещё раз.
export function applyCreatedIds<T extends { id: string }>(
items: T[],
createdById: Record<string, Partial<T>>,
): T[] {
return items.map(item => (createdById[item.id] ? { ...item, ...createdById[item.id] } : item));
}
@@ -4,7 +4,7 @@ import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { buildOfferKey, computeOffer } from './matrixHelpers';
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService';
let extraIdCounter = 0;
@@ -670,6 +670,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3ExtraPositions: updated });
};
// Созданные на «Сохранить» поставщиков и доп. строки запоминаем сразу — в стейт и сессию. Раньше их
// spId попадали в стейт только в самом конце handleSave: упади saveOffers — повторное «Сохранить»
// создало бы в SP ещё одну строку «Логистика» / ещё одного поставщика.
const rememberCreatedExtras = (
supSpIds: Record<string, number>,
offerIds: Record<string, number>,
posSpIds: Record<string, number>,
): void => {
const supplierPatches: Record<string, Partial<ExtraSupplier>> = {};
Object.keys(supSpIds).forEach(id => { supplierPatches[id] = { spId: supSpIds[id], offerId: offerIds[id] }; });
const positionPatches: Record<string, Partial<ExtraPosition>> = {};
Object.keys(posSpIds).forEach(id => { positionPatches[id] = { spId: posSpIds[id] }; });
const nextSuppliers = applyCreatedIds(extraSuppliers, supplierPatches);
const nextPositions = applyCreatedIds(extraPositions, positionPatches);
setExtraSuppliers(nextSuppliers);
setExtraPositions(nextPositions);
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
};
const handleSave = async (): Promise<void> => {
if (!ct) return;
const deliveryMissingKm = extraPositions.filter(ep =>
@@ -685,6 +705,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
try {
const extraSupSpIds: Record<string, number> = {};
const extraOfferIds: Record<string, number> = {};
const extraPosSpIds: Record<string, number> = {};
try {
for (const es of extraSuppliers) {
if (es.spId) {
// Already created in SP — either on a previous save, or eagerly by
@@ -698,7 +720,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
extraOfferIds[es.id] = offerId;
}
const extraPosSpIds: Record<string, number> = {};
for (const ep of extraPositions) {
if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
@@ -721,6 +742,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}]);
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
}
} finally {
rememberCreatedExtras(extraSupSpIds, extraOfferIds, extraPosSpIds);
}
const offersToSave = computedOffers
.filter(o => o.totalWithExtraKzt > 0)