From 373e34b2cf3a0ef87cce6db1401f0363ccecf238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:43:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(ct):=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20SP=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CtService: loadExcludedOffers / replaceExcludedOffers — сначала новые строки, потом прежние в корзину, чтобы сбой записи не терял исключённых; дубли отсеивает чтение. removeCTPositions убирает и исключённые строки снятых позиций. Преобразования строк SP ↔ колонки и офферы формы — отдельно, с тестами. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Step3ComparisonTable/matrixTypes.ts | 3 + .../forms/ct/components/types/domain.ts | 6 + .../components/utils/excludedColumns.test.ts | 110 ++++++++++++++++++ .../ct/components/utils/excludedColumns.ts | 107 +++++++++++++++++ .../forms/ct/services/CtService.ts | 67 +++++++++-- .../shared/offers/excludedOffers.test.ts | 16 ++- .../shared/offers/excludedOffers.ts | 22 +++- 7 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts index 679f45f..c177f0c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts @@ -1,11 +1,14 @@ // Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable. +import { SupplierExclusion } from '../types/domain'; + export interface ExtraSupplier { id: string; name: string; currency: string; spId?: number; status?: string; + exclusion?: SupplierExclusion; // Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»). offerId?: number; bin?: string; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 10e8437..2f60f69 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -73,6 +73,12 @@ export interface Supplier { currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON) adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt' + exclusion?: SupplierExclusion; // исключён из сравнения на Шаге 3 — колонка скрыта, цены сохранены +} + +// Поставщик исключён из сравнения (docs/adr/0001). Хранится в «Исключённые предложения». +export interface SupplierExclusion { + reason: string; } // Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента. diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts new file mode 100644 index 0000000..f5ec41c --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts @@ -0,0 +1,110 @@ +import { SupplierOffer } from '../types/domain'; +import { IExcludedOfferItem, fromExcludedOfferItem, toExcludedOfferBody } from '../../../../shared/offers/excludedOffers'; +import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData } from './excludedColumns'; + +const REASON = 'Дорого'; +const USD_SP_ID = 3; +const currencyCodeOf = (valutaSpId?: number): string => (valutaSpId === USD_SP_ID ? 'USD' : 'KZT'); + +function makeItem(overrides: Partial): IExcludedOfferItem { + return { + id: 1, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON, + positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 100, + price: 10, summa: 4500, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000, + ...overrides, + }; +} + +function makeOffer(overrides: Partial): SupplierOffer { + return { + id: '', supplierId: '259_278', positionId: 'pos-a', itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', + quantity: 100, pricePerUnit: 10, totalPriceKzt: 450000, currencyCode: 'USD', + pricePerUnitKzt: 4500, totalWithExtraKzt: 450000, + ...overrides, + }; +} + +const deps: ExcludedSaveDeps = { + positionSpIdOf: id => ({ 'pos-a': 1476, 'pos-b': 1477 } as Record)[id], + valutaSpIdOf: code => (code === 'USD' ? USD_SP_ID : undefined), + deliveryKmOf: () => undefined, +}; + +describe('excludedItemsToFormData', () => { + it('колонка с причиной и валютой, id колонки — как у обычных (поставщик_файл КП)', () => { + const { suppliers } = excludedItemsToFormData([makeItem({})], currencyCodeOf); + + expect(suppliers).toEqual([{ + id: '259_278', spId: 259, kpFileSpId: 278, name: 'ООО Альфа', hasOffer: true, + currency: 'USD', exclusion: { reason: REASON }, + }]); + }); + + it('офферы привязаны к колонке и SP Id позиции, цены — как при обычной загрузке из SP', () => { + const { offers } = excludedItemsToFormData([makeItem({ itemIndex: 1 })], currencyCodeOf); + + expect(offers).toEqual([expect.objectContaining({ + supplierId: '259_278', positionId: '1476', itemIndex: 1, pricePerUnit: 10, quantity: 100, + currencyCode: 'USD', pricePerUnitKzt: 4500, totalWithExtraKzt: 450000, offerId: '278', + })]); + }); + + it('поставщик без цен — колонка есть, офферов нет', () => { + const { suppliers, offers } = excludedItemsToFormData([makeItem({ positionSpId: undefined })], currencyCodeOf); + + expect(suppliers).toHaveLength(1); + expect(suppliers[0].currency).toBe('KZT'); + expect(offers).toEqual([]); + }); +}); + +describe('buildExcludedRecordsForSave', () => { + const column = { id: '259_278', exclusion: { reason: REASON }, supplierSpId: 259, offerFileSpId: 278 }; + + it('пишет офферы только своей колонки и только непустые (как у обычных поставщиков)', () => { + const records = buildExcludedRecordsForSave([column], [ + makeOffer({ positionId: 'pos-a' }), + makeOffer({ positionId: 'pos-b', totalWithExtraKzt: 0 }), + makeOffer({ supplierId: 'другая_колонка' }), + ], deps); + + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + supplierSpId: 259, offerFileSpId: 278, reason: REASON, positionSpId: 1476, + price: 10, summa: 450000, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000, + }); + }); + + it('позиция ещё не в SP — оффер пропускается, а не пишется без позиции (это был бы маркер)', () => { + const records = buildExcludedRecordsForSave([column], [ + makeOffer({ positionId: 'pos-a' }), + makeOffer({ positionId: 'новая-позиция' }), + ], deps); + + expect(records.map(r => r.positionSpId)).toEqual([1476]); + }); + + it('без цен — строка-маркер', () => { + const records = buildExcludedRecordsForSave([column], [], deps); + + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ supplierSpId: 259, reason: REASON }); + expect(records[0].positionSpId).toBeUndefined(); + }); + + it('колонку без SP Id поставщика не пишет', () => { + expect(buildExcludedRecordsForSave([{ ...column, supplierSpId: undefined }], [makeOffer({})], deps)).toEqual([]); + }); + + it('сохранение → SP → открытие возвращает ту же колонку с теми же ценами', () => { + const records = buildExcludedRecordsForSave([column], [makeOffer({ positionId: 'pos-a', itemIndex: 1 })], deps); + const fromSp = records.map((r, i) => fromExcludedOfferItem({ + ...toExcludedOfferBody(364, r), Id: i + 1, supplier: { Title: 'ООО Альфа' }, + })); + + const { suppliers, offers } = excludedItemsToFormData(fromSp, currencyCodeOf); + + expect(suppliers[0]).toMatchObject({ id: '259_278', exclusion: { reason: REASON }, currency: 'USD' }); + expect(offers[0]).toMatchObject({ positionId: '1476', itemIndex: 1, pricePerUnit: 10, totalWithExtraKzt: 450000 }); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts new file mode 100644 index 0000000..c34ff44 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts @@ -0,0 +1,107 @@ +import { Supplier, SupplierExclusion, SupplierOffer } from '../types/domain'; +import { + IExcludedOfferItem, + IExcludedOfferRecord, + groupExcludedColumns, + toExcludedRecords, +} from '../../../../shared/offers/excludedOffers'; +import { buildSupplierColumnId } from './supplierColumns'; + +// Исключённые поставщики СТ (docs/adr/0001) ↔ колонки и офферы формы. + +export interface ExcludedFormData { + suppliers: Supplier[]; + offers: SupplierOffer[]; +} + +// Открытие СТ из SP: строки «Исключённых предложений» → колонки (с exclusion) и офферы, в том же +// виде, что buildColumnsFromSpOffers / buildOffersFromSpRecords для обычных поставщиков. +export function excludedItemsToFormData( + items: IExcludedOfferItem[], + currencyCodeOf: (valutaSpId?: number) => string, +): ExcludedFormData { + const suppliers: Supplier[] = []; + const offers: SupplierOffer[] = []; + for (const column of groupExcludedColumns(items)) { + const columnId = buildSupplierColumnId(column.supplierSpId, column.offerFileSpId); + suppliers.push({ + id: columnId, + spId: column.supplierSpId, + kpFileSpId: column.offerFileSpId, + name: column.supplierName, + hasOffer: true, + currency: currencyCodeOf(column.offers[0]?.valutaSpId), + exclusion: { reason: column.reason }, + }); + column.offers.forEach(o => offers.push({ + id: '', + supplierId: columnId, + positionId: String(o.positionSpId), + itemIndex: o.itemIndex, + supplierPosition: o.supplierPosition || undefined, + unit: o.unit, + pricePerUnit: o.price, + quantity: o.quantity, + currencyCode: currencyCodeOf(o.valutaSpId), + pricePerUnitKzt: o.priceTenge || o.price, + totalPriceKzt: o.summaTenge ?? o.summa, + totalWithExtraKzt: o.summaTenge ?? o.summa, + offerId: column.offerFileSpId !== undefined ? String(column.offerFileSpId) : undefined, + deliveryDistanceKm: o.deliveryDistanceKm, + })); + } + return { suppliers, offers }; +} + +// Колонка исключённого поставщика на момент сохранения: SP Id поставщика и файла КП уже известны +// (у добавленных на Шаге 3 — после их создания в SP). +export interface ExcludedColumnToSave { + id: string; + exclusion: SupplierExclusion; + supplierSpId?: number; + offerFileSpId?: number; +} + +export interface ExcludedSaveDeps { + // SP Id позиции по id строки таблицы; undefined — позиция ещё не в SP, оффер не пишем. + positionSpIdOf: (positionId: string) => number | undefined; + valutaSpIdOf: (currencyCode: string) => number | undefined; + deliveryKmOf: (positionId: string, columnId: string) => number | undefined; +} + +// «Сохранить» Шага 3: колонки исключённых + их посчитанные офферы → строки для SP. Правила те же, +// что у обычных поставщиков: пустые ячейки (сумма 0) не пишем. +export function buildExcludedRecordsForSave( + columns: ExcludedColumnToSave[], + offers: SupplierOffer[], + deps: ExcludedSaveDeps, +): IExcludedOfferRecord[] { + const records: IExcludedOfferRecord[] = []; + for (const column of columns) { + // Без SP Id поставщика строку не записать (лукап) — такой колонки быть не должно: handleSave + // создаёт поставщиков Шага 3 в SP до записи офферов. + if (column.supplierSpId === undefined) continue; + const columnOffers = offers + .filter(o => o.supplierId === column.id && o.totalWithExtraKzt > 0) + .map(o => ({ offer: o, positionSpId: deps.positionSpIdOf(o.positionId) })) + .filter((x): x is { offer: SupplierOffer; positionSpId: number } => x.positionSpId !== undefined) + .map(({ offer, positionSpId }) => ({ + positionSpId, + itemIndex: offer.itemIndex ?? 0, + supplierPosition: offer.supplierPosition ?? '', + unit: offer.unit ?? '', + quantity: offer.quantity, + price: offer.pricePerUnit, + summa: offer.totalPriceKzt, + valutaSpId: deps.valutaSpIdOf(offer.currencyCode), + priceTenge: offer.pricePerUnitKzt, + summaTenge: offer.totalWithExtraKzt, + deliveryDistanceKm: deps.deliveryKmOf(offer.positionId, column.id), + })); + records.push(...toExcludedRecords( + { supplierSpId: column.supplierSpId, offerFileSpId: column.offerFileSpId, reason: column.exclusion.reason }, + columnOffers, + )); + } + return records; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 2857406..7c2f0d6 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -30,6 +30,13 @@ import { keepLatestOfferRecords, parseOfferItemIndex, } from "../../../shared/offers/offerRecords"; +import { + EXCLUDED_OFFER_SELECT_FIELDS, + IExcludedOfferItem, + IExcludedOfferRecord, + fromExcludedOfferItem, + toExcludedOfferBody, +} from "../../../shared/offers/excludedOffers"; import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria"; import { pickRowToNotify } from "../components/utils/approvalAccess"; import { buildPositionKeys } from "../components/utils/positionKey"; @@ -65,6 +72,9 @@ const LISTS = { SUPPLIERS: "Поставщики", NO_OFFERS: "Не получены КП", OFFERS: "Оценка поставщиков", + // Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001) — отдельно от OFFERS, + // чтобы отчёт СТ, INCORE и потоки их не видели. + EXCLUDED_OFFERS: "Исключённые предложения", CURRENCY_RATES: "Курс Валют", LIBRARY_KP: "Коммерческие предложения", CRITERIA: "Критерии", @@ -283,25 +293,28 @@ export class CtService { if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`); } - // Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину. - // Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков». + // Позиции, снятые при повторном сохранении шага 1: их предложения (в т.ч. исключённых + // поставщиков) и сами позиции — в корзину. Сначала предложения: без позиции они бы остались висеть. async removeCTPositions(positionSpIds: number[]): Promise { if (positionSpIds.length === 0) return; const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or "); - const res = await this.http.get( - `${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`, - SPHttpClient.configurations.v1, - ); - if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`); - const data = await res.json(); - const offerIds = ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id); + const loadIds = async (listName: string): Promise => { + const res = await this.http.get( + `${this._li(listName)}?$select=Id&$filter=${filter}&$top=5000`, + SPHttpClient.configurations.v1, + ); + if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`); + const data = await res.json(); + return ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id); + }; // MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers. const recycle = (listName: string) => async (id: number): Promise => { await this.http .post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {}) .catch(() => undefined /* уже в корзине / не найден — игнорируем */); }; - await this._runChunked(offerIds, 20, recycle(LISTS.OFFERS)); + await this._runChunked(await loadIds(LISTS.OFFERS), 20, recycle(LISTS.OFFERS)); + await this._runChunked(await loadIds(LISTS.EXCLUDED_OFFERS), 20, recycle(LISTS.EXCLUDED_OFFERS)); await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS)); } @@ -1361,6 +1374,40 @@ export class CtService { } } + // ─── Исключённые предложения ─────────────────────────────────────────────── + + async loadExcludedOffers(ctId: string): Promise { + const url = + `${this._li(LISTS.EXCLUDED_OFFERS)}?$select=${EXCLUDED_OFFER_SELECT_FIELDS.join(",")}` + + `&$expand=supplier&$filter=CTId eq ${parseInt(ctId, 10)}&$top=5000`; + const res = await this.http.get(url, SPHttpClient.configurations.v1); + if (!res.ok) throw new Error(`Ошибка чтения исключённых поставщиков: ${res.status}`); + const data = await res.json(); + return ((data.value ?? []) as Array>).map(fromExcludedOfferItem); + } + + // Приводит исключённых этой СТ к текущему Шагу 3. Сначала новые строки, потом прежние в корзину: + // упади запись посередине — прежние исключённые останутся, а дубли отсеет чтение + // (groupExcludedColumns). Вернувшиеся в сравнение поставщики уходят вместе с прежними строками. + async replaceExcludedOffers(ctId: string, records: IExcludedOfferRecord[]): Promise { + const ctSpId = parseInt(ctId, 10); + const previousIds = (await this.loadExcludedOffers(ctId)).map((item) => item.id); + if (records.length === 0 && previousIds.length === 0) return; + + await this._runChunked(records, 15, async (record) => { + const res = await this.http.post(this._li(LISTS.EXCLUDED_OFFERS), SPHttpClient.configurations.v1, { + headers: { "Content-Type": "application/json;odata=nometadata" }, + body: JSON.stringify(toExcludedOfferBody(ctSpId, record)), + }); + if (!res.ok) throw new Error(`Ошибка сохранения исключённого поставщика: ${res.status}`); + }); + await this._runChunked(previousIds, 20, async (id) => { + await this.http + .post(`${this._liById(LISTS.EXCLUDED_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {}) + .catch(() => undefined /* уже в корзине / не найден — игнорируем */); + }); + } + async getCurrentUserProfile(): Promise<{ department: string; company: string; diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts index f24ef7f..8269197 100644 --- a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts @@ -14,6 +14,7 @@ const REASON = 'Не прошёл проверку Adata'; function makeItem(overrides: Partial): IExcludedOfferItem { return { + id: 1, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, @@ -96,7 +97,7 @@ describe('fromExcludedOfferItem', () => { }); expect(record).toEqual({ - supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON, + id: 10, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON, positionSpId: 1476, itemIndex: 1, supplierPosition: 'Кабель', unit: 'м', quantity: 100, price: 450, summa: 45000, valutaSpId: 3, priceTenge: 450, summaTenge: 45000, deliveryDistanceKm: undefined, @@ -136,6 +137,19 @@ describe('groupExcludedColumns', () => { expect(columns[0].offers).toEqual([]); }); + // Сохранение упало между созданием новых строк и уборкой прежних — в SP обе версии. + it('из дублей оставляет последнюю строку, причина — из последнего сохранения', () => { + const columns = groupExcludedColumns([ + makeItem({ id: 10, positionSpId: 1476, price: 400, reason: 'Дорого' }), + makeItem({ id: 11, positionSpId: 1477, price: 500, reason: 'Дорого' }), + makeItem({ id: 20, positionSpId: 1476, price: 450, reason: REASON }), + ]); + + expect(columns).toHaveLength(1); + expect(columns[0].reason).toBe(REASON); + expect(columns[0].offers.map(o => `${o.positionSpId}:${o.price}`)).toEqual(['1476:450', '1477:500']); + }); + it('строки упорядочены по позиции и номеру строки комплекта', () => { const columns = groupExcludedColumns([ makeItem({ positionSpId: 1477, itemIndex: 0 }), diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts index 72dae26..700cf0f 100644 --- a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts @@ -26,8 +26,10 @@ export interface IExcludedOfferRecord { deliveryDistanceKm?: number; } -// Строка, прочитанная из SP: дополнительно имя поставщика из лукапа. +// Строка, прочитанная из SP: дополнительно SP Id и имя поставщика из лукапа. export interface IExcludedOfferItem extends IExcludedOfferRecord { + // Чем больше, тем позже создана — по нему отсеиваются дубли (см. groupExcludedColumns). + id: number; supplierName: string; } @@ -99,6 +101,7 @@ export function toExcludedOfferBody(ctSpId: number, record: IExcludedOfferRecord export function fromExcludedOfferItem(item: Record): IExcludedOfferItem { const supplier = item.supplier as Record | undefined; return { + id: Number(item.Id ?? 0), supplierSpId: Number(item.supplierId), supplierName: String(supplier?.Title ?? ''), offerFileSpId: optionalNumber(item.offerFileId), @@ -121,10 +124,25 @@ function columnKey(supplierSpId: number, offerFileSpId?: number): string { return `${supplierSpId}::${offerFileSpId ?? ''}`; } +// Одна строка на (колонка, позиция, строка комплекта) — последняя по Id. Дубли остаются, если +// сохранение упало между созданием новых строк и уборкой прежних (сервис пишет в этом порядке, +// чтобы при сбое не потерять исключённых). +function keepLatestExcluded(items: IExcludedOfferItem[]): IExcludedOfferItem[] { + const latestByKey = new Map(); + for (const item of items) { + const key = `${columnKey(item.supplierSpId, item.offerFileSpId)}::${item.positionSpId ?? ''}::${item.itemIndex}`; + const previous = latestByKey.get(key); + if (!previous || item.id > previous.id) latestByKey.set(key, item); + } + return Array.from(latestByKey.values()); +} + // Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения. export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] { const columns = new Map(); - for (const item of items) { + // Новые строки первыми: причина колонки берётся из последнего сохранения. + const newestFirst = keepLatestExcluded(items).sort((a, b) => b.id - a.id); + for (const item of newestFirst) { const key = columnKey(item.supplierSpId, item.offerFileSpId); let column = columns.get(key); if (!column) {