diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts new file mode 100644 index 0000000..2ab285d --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.test.ts @@ -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(); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts index 3952235..8c859a3 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixHelpers.ts @@ -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( + items: T[], + createdById: Record>, +): T[] { + return items.map(item => (createdById[item.id] ? { ...item, ...createdById[item.id] } : item)); +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts index afbf0c6..a4a10eb 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts @@ -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, + offerIds: Record, + posSpIds: Record, + ): void => { + const supplierPatches: Record> = {}; + Object.keys(supSpIds).forEach(id => { supplierPatches[id] = { spId: supSpIds[id], offerId: offerIds[id] }; }); + const positionPatches: Record> = {}; + 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 => { if (!ct) return; const deliveryMissingKm = extraPositions.filter(ep => @@ -685,41 +705,45 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { try { const extraSupSpIds: Record = {}; const extraOfferIds: Record = {}; - 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 = {}; - for (const ep of extraPositions) { - if (ep.spId) { - // Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows. - extraPosSpIds[ep.id] = ep.spId; - continue; + try { + 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 saved = await service.createCTPositions(ct.id, [{ - id: ep.id, - requestId: '', - requestNumber: '', - materialName: ep.materialName, - disciplineCode: '', - smrType: '', - unit: ep.unit, - agskCode: '', - uniqueCode: '', - quantity: 0, - positionType: 'Дополнительные затраты', - deadline: '', - }]); - if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId; + + for (const ep of extraPositions) { + if (ep.spId) { + // Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows. + extraPosSpIds[ep.id] = ep.spId; + continue; + } + const saved = await service.createCTPositions(ct.id, [{ + id: ep.id, + requestId: '', + requestNumber: '', + materialName: ep.materialName, + disciplineCode: '', + smrType: '', + 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