From 76ad9c62425ba0070a6e3959ba87dea76eeeda2e 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: Wed, 30 Sep 2026 15:05:08 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B5=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5=20=D1=81?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7?= =?UTF-8?q?-=D0=B7=D0=B0=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=9A=D0=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Повторная обработка КП добавляет в «Оценка поставщиков» новые записи, а прежние удаляются только по «Сохранить» — в SP копились дубли (напр. СТ №89), и шаг 3 рисовал позицию несколькими одинаковыми под-строками. Загрузчики офферов теперь оставляют одну запись на (поставщик, позиция, itemIndex) — последнюю по Id. Загрузка по ссылке/без сессии заодно восстанавливает itemIndex из Title: раньше все строки комплекта шли как 0. Дедуп вынесен в shared/offers, чтобы переиспользовать в ОИД и КПС. --- .../forms/ct/components/CtCreateForm.tsx | 1 + .../forms/ct/services/CtService.ts | 36 +++++------ .../shared/offers/offerRecords.test.ts | 59 +++++++++++++++++++ .../shared/offers/offerRecords.ts | 32 ++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts create mode 100644 src/webparts/ctFormsPortal/shared/offers/offerRecords.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index e60e8c1..d4da69c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -127,6 +127,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, id: '', supplierId: String(o.supplierSpId), positionId: String(o.positionSpId), + itemIndex: o.itemIndex, unit: o.unit, pricePerUnit: o.price, quantity: o.quantity, diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 31a46d6..adaa132 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -24,6 +24,7 @@ import { getRequestTypeIdByPageUrl, writeRoute, } from "../../../shared/approval"; +import { keepLatestOfferRecords, parseOfferItemIndex } from "../../../shared/offers/offerRecords"; // Round monetary values to 2 decimals before persisting to SharePoint (drops float tails). function round2(value: number): number { @@ -779,6 +780,7 @@ export class CtService { priceTenge: number; summaTenge: number; currencyCode: string; + itemIndex: number; valutaSpId?: number; deliveryDistanceKm?: number; offerFileSpId?: number; @@ -792,18 +794,20 @@ export class CtService { // valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds. const url = `${this._li(LISTS.OFFERS)}` + - `?$select=positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` + + `?$select=Id,Title,positionId,supplierId,price,quantity,unit,priceTenge,summaTenge,valutaId,deliveryDistanceKm,offerFileId,supplier/Id,supplier/Title` + `&$expand=supplier` + `&$filter=${filter}` + `&$top=2000`; const res = await this.http.get(url, SPHttpClient.configurations.v1); if (!res.ok) return []; const data = await res.json(); - return (data.value ?? []).map((item: Record) => { + const records = ((data.value ?? []) as Array>).map((item) => { const sup = item["supplier"] as Record | undefined; const rawValutaId = item["valutaId"]; const valutaSpId = rawValutaId ? Number(rawValutaId) : undefined; return { + id: Number(item["Id"]), + itemIndex: parseOfferItemIndex(String(item["Title"] ?? "")), positionSpId: Number(item["positionId"]), supplierSpId: Number(item["supplierId"]), supplierName: String(sup?.["Title"] ?? ""), @@ -818,6 +822,7 @@ export class CtService { offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined, }; }); + return keepLatestOfferRecords(records); } async loadKpFilesForOffers(positionSpIds: number[]): Promise< @@ -1538,24 +1543,21 @@ export class CtService { const filter = positionSpIds .map((id) => `positionId eq ${id}`) .join(" or "); - const url = `${this._li(LISTS.OFFERS)}?$select=Title,positionId,supplierId,price,quantity,supplierPosition,unit&$filter=${filter}`; + const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit&$filter=${filter}`; const res = await this.http.get(url, SPHttpClient.configurations.v1); if (!res.ok) return []; const data = await res.json(); - return (data.value ?? []).map((item: Record) => { - // Title format: "supId-posId" (old, index 0) or "supId-posId-N" (bundle row N) - const parts = String(item.Title ?? "").split("-"); - const itemIndex = parts.length >= 3 ? parseInt(parts[2], 10) || 0 : 0; - return { - positionSpId: Number(item.positionId), - supplierSpId: Number(item.supplierId), - price: Number(item.price ?? 0), - quantity: Number(item.quantity ?? 0), - supplierPosition: String(item.supplierPosition ?? ""), - unit: String(item.unit ?? ""), - itemIndex, - }; - }); + const records = ((data.value ?? []) as Array>).map((item) => ({ + id: Number(item.Id), + positionSpId: Number(item.positionId), + supplierSpId: Number(item.supplierId), + price: Number(item.price ?? 0), + quantity: Number(item.quantity ?? 0), + supplierPosition: String(item.supplierPosition ?? ""), + unit: String(item.unit ?? ""), + itemIndex: parseOfferItemIndex(String(item.Title ?? "")), + })); + return keepLatestOfferRecords(records); } async saveKpOffer(params: { diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts new file mode 100644 index 0000000..bcb005f --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts @@ -0,0 +1,59 @@ +import { IOfferRecordKey, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords'; + +interface ITestRecord extends IOfferRecordKey { + price: number; +} + +function makeRecord(overrides: Partial): ITestRecord { + return { id: 1, supplierSpId: 259, positionSpId: 1476, itemIndex: 0, price: 100, ...overrides }; +} + +describe('parseOfferItemIndex', () => { + it('«поставщик-позиция» — первая строка КП (0)', () => { + expect(parseOfferItemIndex('259-1476')).toBe(0); + }); + + it('«поставщик-позиция-N» — строка комплекта N', () => { + expect(parseOfferItemIndex('259-1476-2')).toBe(2); + }); + + it('пустой или битый Title считает первой строкой', () => { + expect(parseOfferItemIndex(undefined)).toBe(0); + expect(parseOfferItemIndex('')).toBe(0); + expect(parseOfferItemIndex('259-1476-x')).toBe(0); + }); +}); + +describe('keepLatestOfferRecords', () => { + // Как в СТ №89: КП поставщика 259 обработан дважды → по две записи на позицию, повторная новее. + it('оставляет одну запись на поставщика и позицию — последнюю по Id', () => { + const records = [ + makeRecord({ id: 1836, positionSpId: 1478, price: 100 }), + makeRecord({ id: 1850, positionSpId: 1478, price: 120 }), + makeRecord({ id: 1847, positionSpId: 1476, price: 55 }), + makeRecord({ id: 1837, positionSpId: 1476, price: 50 }), + ]; + + const result = keepLatestOfferRecords(records); + + expect(result.map(r => r.id).sort()).toEqual([1847, 1850]); + }); + + it('строки комплекта (разный itemIndex) не схлопывает', () => { + const records = [ + makeRecord({ id: 10, itemIndex: 0 }), + makeRecord({ id: 11, itemIndex: 1 }), + ]; + + expect(keepLatestOfferRecords(records)).toHaveLength(2); + }); + + it('одну позицию у разных поставщиков не схлопывает', () => { + const records = [ + makeRecord({ id: 10, supplierSpId: 259 }), + makeRecord({ id: 11, supplierSpId: 233 }), + ]; + + expect(keepLatestOfferRecords(records)).toHaveLength(2); + }); +}); diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts new file mode 100644 index 0000000..2b3c076 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts @@ -0,0 +1,32 @@ +// Записи списка «Оценка поставщиков»: одна запись = одна строка КП поставщика по позиции. +// У позиции может быть несколько строк КП (комплект) — они различаются itemIndex. + +export interface IOfferRecordKey { + // SP Id записи — чем больше, тем позже создана. + id: number; + supplierSpId: number; + positionSpId: number; + itemIndex: number; +} + +// Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N). +export function parseOfferItemIndex(title: string | undefined): number { + const parts = (title ?? '').split('-'); + if (parts.length < 3) return 0; + return parseInt(parts[2], 10) || 0; +} + +// Одна запись на (поставщик, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся, +// когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только +// при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками. +export function keepLatestOfferRecords(records: T[]): T[] { + const latestByKey = new Map(); + for (const record of records) { + const key = `${record.supplierSpId}::${record.positionSpId}::${record.itemIndex}`; + const previous = latestByKey.get(key); + if (!previous || record.id > previous.id) { + latestByKey.set(key, record); + } + } + return Array.from(latestByKey.values()); +}