diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.test.ts new file mode 100644 index 0000000..c62b045 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts index 39f1d0a..a6e842f 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts @@ -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(); - 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, diff --git a/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.test.ts b/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.test.ts new file mode 100644 index 0000000..b261786 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.test.ts @@ -0,0 +1,56 @@ +import { buildKpCriteria, buildKpOffers, buildKpSuppliers } from './kpOffers'; +import { KpFile } from './kpTypes'; + +function makeKpFile(overrides: Partial): 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']); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.ts b/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.ts index 9388d2e..7dab6f9 100644 --- a/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.ts +++ b/src/webparts/ctFormsPortal/forms/kps/components/Step2LoadKp/kpOffers.ts @@ -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; } diff --git a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts index 455f551..7ac4b59 100644 --- a/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts +++ b/src/webparts/ctFormsPortal/forms/kps/services/KpsCtFormService.ts @@ -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(); - 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). diff --git a/src/webparts/ctFormsPortal/shared/offers/kpFiles.ts b/src/webparts/ctFormsPortal/shared/offers/kpFiles.ts new file mode 100644 index 0000000..c65f7c9 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/kpFiles.ts @@ -0,0 +1,24 @@ +// Файл КП в обработке — общее подмножество полей KpFile форм СТ и КПС. +export interface IKpFileForSupplier { + supplierName?: string; + supplierSpId?: number; + status: string; +} + +const DONE_STATUS = 'done'; + +// Несколько КП одного поставщика (повторная загрузка, тот же КП под другим именем файла) получают +// один и тот же supplierSpId — поставщик находится по имени. Без отбора в таблице сравнения +// появляется дубль колонки с тем же id, а офферы обоих файлов складываются в одну ячейку. +// Оставляем последний загруженный файл поставщика, но обработанный не вытесняем тем, что ещё в процессе. +export function pickLatestKpFilePerSupplier(kpFiles: T[]): T[] { + const fileBySupplier = new Map(); + 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()); +}