fix(ct): повторное «Сохранить» на шаге 3 не создаёт доп. строки и поставщиков второй раз
Доп. строки («Логистика», «Доп. расходы») и новые поставщики шага 3 создаются в SP в начале сохранения, а их SP-id попадали в стейт только в самом конце. Если дальше падало сохранение офферов, id терялись, и повторное «Сохранить» создавало те же строки ещё раз. Теперь id запоминаются сразу после создания — и при успехе, и при сбое (applyCreatedIds). Заодно в репозиторий попадают тесты расчёта сумм matrixHelpers.test.ts.
This commit is contained in:
+102
@@ -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 {
|
export function buildOfferKey(supplierId: string, positionId: string): string {
|
||||||
return `${supplierId}::${positionId}`;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
+58
-34
@@ -4,7 +4,7 @@ import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
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';
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
|
|
||||||
let extraIdCounter = 0;
|
let extraIdCounter = 0;
|
||||||
@@ -670,6 +670,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3ExtraPositions: updated });
|
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> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
if (!ct) return;
|
if (!ct) return;
|
||||||
const deliveryMissingKm = extraPositions.filter(ep =>
|
const deliveryMissingKm = extraPositions.filter(ep =>
|
||||||
@@ -685,41 +705,45 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
try {
|
try {
|
||||||
const extraSupSpIds: Record<string, number> = {};
|
const extraSupSpIds: Record<string, number> = {};
|
||||||
const extraOfferIds: Record<string, number> = {};
|
const extraOfferIds: Record<string, number> = {};
|
||||||
for (const es of extraSuppliers) {
|
|
||||||
if (es.spId) {
|
|
||||||
// Already created in SP — either on a previous save, or eagerly by
|
|
||||||
// handleAddSupplierFromDatabase (which also sets es.offerId). Reuse to avoid duplicates.
|
|
||||||
extraSupSpIds[es.id] = es.spId;
|
|
||||||
if (es.offerId) extraOfferIds[es.id] = es.offerId;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
|
|
||||||
extraSupSpIds[es.id] = spId;
|
|
||||||
extraOfferIds[es.id] = offerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraPosSpIds: Record<string, number> = {};
|
const extraPosSpIds: Record<string, number> = {};
|
||||||
for (const ep of extraPositions) {
|
try {
|
||||||
if (ep.spId) {
|
for (const es of extraSuppliers) {
|
||||||
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
if (es.spId) {
|
||||||
extraPosSpIds[ep.id] = ep.spId;
|
// Already created in SP — either on a previous save, or eagerly by
|
||||||
continue;
|
// handleAddSupplierFromDatabase (which also sets es.offerId). Reuse to avoid duplicates.
|
||||||
|
extraSupSpIds[es.id] = es.spId;
|
||||||
|
if (es.offerId) extraOfferIds[es.id] = es.offerId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
|
||||||
|
extraSupSpIds[es.id] = spId;
|
||||||
|
extraOfferIds[es.id] = offerId;
|
||||||
}
|
}
|
||||||
const saved = await service.createCTPositions(ct.id, [{
|
|
||||||
id: ep.id,
|
for (const ep of extraPositions) {
|
||||||
requestId: '',
|
if (ep.spId) {
|
||||||
requestNumber: '',
|
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
||||||
materialName: ep.materialName,
|
extraPosSpIds[ep.id] = ep.spId;
|
||||||
disciplineCode: '',
|
continue;
|
||||||
smrType: '',
|
}
|
||||||
unit: ep.unit,
|
const saved = await service.createCTPositions(ct.id, [{
|
||||||
agskCode: '',
|
id: ep.id,
|
||||||
uniqueCode: '',
|
requestId: '',
|
||||||
quantity: 0,
|
requestNumber: '',
|
||||||
positionType: 'Дополнительные затраты',
|
materialName: ep.materialName,
|
||||||
deadline: '',
|
disciplineCode: '',
|
||||||
}]);
|
smrType: '',
|
||||||
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
|
unit: ep.unit,
|
||||||
|
agskCode: '',
|
||||||
|
uniqueCode: '',
|
||||||
|
quantity: 0,
|
||||||
|
positionType: 'Дополнительные затраты',
|
||||||
|
deadline: '',
|
||||||
|
}]);
|
||||||
|
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rememberCreatedExtras(extraSupSpIds, extraOfferIds, extraPosSpIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offersToSave = computedOffers
|
const offersToSave = computedOffers
|
||||||
|
|||||||
Reference in New Issue
Block a user