diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index adaa132..a4cc4d8 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -24,7 +24,11 @@ import { getRequestTypeIdByPageUrl, writeRoute, } from "../../../shared/approval"; -import { keepLatestOfferRecords, parseOfferItemIndex } from "../../../shared/offers/offerRecords"; +import { + findSupersededOfferIds, + keepLatestOfferRecords, + parseOfferItemIndex, +} from "../../../shared/offers/offerRecords"; // Round monetary values to 2 decimals before persisting to SharePoint (drops float tails). function round2(value: number): number { @@ -1598,6 +1602,46 @@ export class CtService { ); if (!res.ok) throw new Error(`Ошибка сохранения предложения: ${res.status}`); + + // Повторная обработка КП (исправленный КП, файл загружен заново) раньше добавляла записи рядом + // с прежними — в SP копились дубли, если потом не нажимали «Сохранить». Сначала создаём новую + // запись, потом убираем прежние: упади создание — старое предложение останется. + const created = (await res.json()) as { Id: number }; + await this._recycleSupersededOffers({ + id: created.Id, + supplierSpId: params.supplierSpId, + positionSpId: params.positionSpId, + }); + } + + private async _recycleSupersededOffers(saved: { + id: number; + supplierSpId: number; + positionSpId: number; + }): Promise { + const url = + `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId` + + `&$filter=supplierId eq ${saved.supplierSpId} and positionId eq ${saved.positionSpId}`; + const res = await this.http.get(url, SPHttpClient.configurations.v1); + // Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords), + // а «Сохранить» перезапишет записи позиции целиком. Но молча не глотаем. + if (!res.ok) { + console.warn(`[CtService] не удалось прочитать прежние предложения: ${res.status}`); + return; + } + const data = await res.json(); + const existing = ((data.value ?? []) as Array>).map((item) => ({ + id: Number(item.Id), + supplierSpId: Number(item.supplierId), + positionSpId: Number(item.positionId), + itemIndex: parseOfferItemIndex(String(item.Title ?? "")), + })); + // MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers. + await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => { + await this.http + .post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {}) + .catch(() => undefined /* уже в корзине / не найден — игнорируем */); + }); } // ─── Mapping ────────────────────────────────────────────────────────────── diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts index bcb005f..bf09e2a 100644 --- a/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts @@ -1,4 +1,4 @@ -import { IOfferRecordKey, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords'; +import { IOfferRecordKey, findSupersededOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords'; interface ITestRecord extends IOfferRecordKey { price: number; @@ -57,3 +57,40 @@ describe('keepLatestOfferRecords', () => { expect(keepLatestOfferRecords(records)).toHaveLength(2); }); }); + +describe('findSupersededOfferIds', () => { + // Повторная обработка КП поставщика 259: новая запись 1850 заменяет прежнее предложение по позиции. + const saved = makeRecord({ id: 1850, positionSpId: 1478 }); + + it('возвращает прежние записи того же поставщика по той же позиции', () => { + const existing = [makeRecord({ id: 1836, positionSpId: 1478 }), saved]; + + expect(findSupersededOfferIds(existing, saved)).toEqual([1836]); + }); + + it('прежние строки комплекта тоже заменяются новым КП', () => { + const existing = [ + makeRecord({ id: 1836, positionSpId: 1478, itemIndex: 0 }), + makeRecord({ id: 1837, positionSpId: 1478, itemIndex: 1 }), + saved, + ]; + + expect(findSupersededOfferIds(existing, saved)).toEqual([1836, 1837]); + }); + + it('не трогает другие позиции и других поставщиков', () => { + const existing = [ + makeRecord({ id: 1836, positionSpId: 1476 }), + makeRecord({ id: 1837, positionSpId: 1478, supplierSpId: 233 }), + saved, + ]; + + expect(findSupersededOfferIds(existing, saved)).toEqual([]); + }); + + it('не трогает запись новее сохранённой (параллельная обработка)', () => { + const existing = [saved, makeRecord({ id: 1900, positionSpId: 1478 })]; + + expect(findSupersededOfferIds(existing, saved)).toEqual([]); + }); +}); diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts index 2b3c076..700d2dc 100644 --- a/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts @@ -30,3 +30,19 @@ export function keepLatestOfferRecords(records: T[]): } return Array.from(latestByKey.values()); } + +// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика по той же позиции, +// включая строки комплекта — новый КП поставщика заменяет его прежнее предложение целиком. +// Записи новее сохранённой не трогаем: их могла создать параллельная обработка. +export function findSupersededOfferIds( + existing: IOfferRecordKey[], + saved: Pick, +): number[] { + return existing + .filter(record => + record.supplierSpId === saved.supplierSpId && + record.positionSpId === saved.positionSpId && + record.id < saved.id + ) + .map(record => record.id); +}