From 62ebcb2f329d7afdace7db08be4ee8b43bc1a77d 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: Thu, 22 Oct 2026 09:20:58 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20id=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=E2=80=94=20=D0=BF=D0=BE=20=D0=B5=D1=91=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D0=BE=D0=BC?= =?UTF-8?q?=D1=83,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=83=20=D0=B2=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API /Requests/{oid}/positions отдаёт позиции без id и в разном порядке (lineNumber — номер после сортировки по наименованию). id вида GUID__номер указывал на разные позиции: на шаге 1 снимали одну — из СТ удалялась другая. Ключ — GUID потребности + хеш наименования, R66, АГСК, ед. изм., количества и вида СМР; одинаковые строки получают номер совпадения. Метка h- отличает ключ от старых номерных — те ни с чем не сопоставятся. --- .../Step1PositionSelection/step1Plan.test.ts | 2 +- .../Step1PositionSelection/step1Plan.ts | 2 +- .../forms/ct/components/types/domain.ts | 2 +- .../ct/components/utils/positionKey.test.ts | 70 +++++++++++++++++++ .../forms/ct/components/utils/positionKey.ts | 54 ++++++++++++++ .../forms/ct/services/CtService.ts | 22 ++++-- 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts index 90cbead..10ed1a2 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.test.ts @@ -61,7 +61,7 @@ describe('planStep1Save (СТ уже создана — повторное со }); describe('countSelectedInRequest', () => { - // Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер». + // Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__». it('считает выбранные позиции потребности без загрузки её позиций', () => { const selected = new Set(['req1__0', 'req1__4', 'req2__0']); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts index b6b0767..42565b9 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step1PositionSelection/step1Plan.ts @@ -1,6 +1,6 @@ import { Position, Project, Request } from '../types/domain'; -// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition). +// id позиции потребности начинается с `${id потребности}__` (см. utils/positionKey). const POSITION_ID_SEPARATOR = '__'; // Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра), diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 932a51b..10e8437 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -7,7 +7,7 @@ export interface Project { export interface Position { id: string; spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions) - // Позиция потребности, из которой взята позиция СТ: `${GUID потребности}__${номер}` — id позиции + // Позиция потребности, из которой взята позиция СТ: её ключ (см. utils/positionKey) — id позиции // в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто. requestPositionId?: string; requestId: string; // GUID потребности diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.test.ts new file mode 100644 index 0000000..5148089 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.test.ts @@ -0,0 +1,70 @@ +import { buildPositionKeys, PositionKeySource } from './positionKey'; + +function makeSource(materialName: string, overrides: Partial = {}): PositionKeySource { + return { + materialName, + r66Description: `R66 ${materialName}`, + agskCode: '', + unit: 'Штука', + quantity: 10, + smrType: 'ГКЛ перегородки', + ...overrides, + }; +} + +const REQUEST_ID = 'ace2f40343f5cfe011f1c60cee00e6ca'; + +describe('buildPositionKeys (id позиции потребности)', () => { + // API /Requests/{oid}/positions отдаёт позиции без id и каждый раз в разном порядке (потребность + // 081/26-10/0028: три разных порядка за день, lineNumber — номер после сортировки по алфавиту). + // id был `GUID__номер в ответе` → снял «Дюбель» на шаге 1 — удалилась «ЦСП». + const dowel = makeSource('Дюбель-гвоздь 6×40', { quantity: 15275 }); + const csp = makeSource('Цементно-стружечная плита ЦСП толщиной 12 мм', { quantity: 6211, unit: 'Квадратный метр' }); + const tape = makeSource('Лента армирующая', { quantity: 15669, unit: 'Метр' }); + + it('ключ позиции не зависит от порядка в ответе API', () => { + const [dowelFirst, cspFirst] = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]); + const reordered = buildPositionKeys(REQUEST_ID, [tape, csp, dowel]); + + expect(reordered[2]).toBe(dowelFirst); + expect(reordered[1]).toBe(cspFirst); + }); + + it('у разных позиций — разные ключи', () => { + const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]); + + expect(new Set(keys).size).toBe(3); + }); + + it('ключ начинается с id потребности — по нему считаем выбранные в потребности', () => { + const [key] = buildPositionKeys(REQUEST_ID, [dowel]); + + expect(key.startsWith(`${REQUEST_ID}__`)).toBe(true); + }); + + it('не совпадает со старым ключом `GUID__номер` — старые записи ни с чем не сопоставятся', () => { + const [key] = buildPositionKeys(REQUEST_ID, [dowel]); + + const suffix = key.slice(`${REQUEST_ID}__`.length); + expect(suffix).toMatch(/^h-[0-9a-f]{8}$/); + }); + + it('лишние пробелы и регистр не меняют ключ (в API «Саморез … 3.5х25 » с пробелом на конце)', () => { + const [plain] = buildPositionKeys(REQUEST_ID, [makeSource('Саморез для гипсокартона, 3.5х25')]); + const [spaced] = buildPositionKeys(REQUEST_ID, [makeSource(' Саморез для гипсокартона, 3.5х25 ')]); + + expect(spaced).toBe(plain); + }); + + it('другое количество — другая позиция', () => { + const [a, b] = buildPositionKeys(REQUEST_ID, [dowel, { ...dowel, quantity: 100 }]); + + expect(a).not.toBe(b); + }); + + it('одинаковые строки — номер совпадения, ключи различаются', () => { + const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, dowel]); + + expect(keys[2]).toBe(`${keys[0]}-2`); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.ts new file mode 100644 index 0000000..8f4572c --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/positionKey.ts @@ -0,0 +1,54 @@ +// Поля позиции потребности из API, по которым строим её ключ. null из API приводим к '' заранее. +export interface PositionKeySource { + materialName: string; + r66Description: string; + agskCode: string; + unit: string; + quantity: number; + smrType: string; +} + +// Метка в ключе: отличает его от старого `GUID__номер в ответе` (так id строился до 2026-10-22), +// чтобы такие записи гарантированно ни с чем не сопоставлялись. +const KEY_MARK = 'h-'; + +function normalize(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +// FNV-1a, 32 бита → 8 hex-символов. Криптостойкость не нужна — только короткий стабильный отпечаток. +function hash32(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +function contentFingerprint(item: PositionKeySource): string { + return hash32([ + normalize(item.materialName), + normalize(item.r66Description), + normalize(item.agskCode), + normalize(item.unit), + String(item.quantity), + normalize(item.smrType), + ].join('|')); +} + +// Ключ позиции потребности: `${GUID потребности}__h-${отпечаток содержимого}`. +// API отдаёт позиции без id и каждый раз в разном порядке (lineNumber — номер после сортировки по +// наименованию, сдвигается при добавлении позиции), поэтому номер в ответе как id не годится: +// снимали на шаге 1 одну позицию — удалялась другая. Ключ из содержимого от порядка не зависит. +// Одинаковые строки получают номер совпадения (`-2`, `-3`): они неразличимы, путать их не страшно. +// Если позицию в потребности отредактируют, ключ изменится и она просто перестанет быть отмеченной. +export function buildPositionKeys(requestId: string, items: PositionKeySource[]): string[] { + const seen = new Map(); + return items.map(item => { + const base = `${requestId}__${KEY_MARK}${contentFingerprint(item)}`; + const occurrence = (seen.get(base) ?? 0) + 1; + seen.set(base, occurrence); + return occurrence === 1 ? base : `${base}-${occurrence}`; + }); +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 2be103c..dafe497 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -32,6 +32,7 @@ import { } from "../../../shared/offers/offerRecords"; import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria"; import { pickRowToNotify } from "../components/utils/approvalAccess"; +import { buildPositionKeys } from "../components/utils/positionKey"; import { NoOfferSupplierEntry, NoOfferSupplierRecord, @@ -209,8 +210,17 @@ export class CtService { }); if (!res.ok) return []; const data: ApiPosition[] = await res.json(); + // id позиции — из её содержимого, не из места в ответе: API отдаёт позиции в разном порядке. + const keys = buildPositionKeys(oid, data.map((item) => ({ + materialName: String(item.materialName ?? ""), + r66Description: String(item.r66Description ?? ""), + agskCode: String(item.agskCode ?? ""), + unit: String(item.unit ?? ""), + quantity: Number(item.quantity ?? 0), + smrType: String(item.smrType ?? ""), + }))); return data.map((item, idx) => - this._mapPosition(item, oid, requestNumber, idx), + this._mapPosition(item, keys[idx], oid, requestNumber), ); } @@ -814,7 +824,7 @@ export class CtService { if (!res.ok) return []; const data = await res.json(); return (data.value ?? []).map((item: Record): Position => { - // `${GUID потребности}__${номер}`; у СТ, созданных до появления поля, пусто. + // Ключ позиции потребности (см. utils/positionKey); у СТ, созданных до появления поля, пусто. const requestPositionId = String(item.requestPositionId ?? ""); return { // id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers). @@ -1795,11 +1805,11 @@ export class CtService { private _mapPosition = ( item: ApiPosition, - requestOid = "", - requestNumber = "", - idx = 0, + positionKey: string, + requestOid: string, + requestNumber: string, ): Position => ({ - id: `${requestOid}__${idx}`, + id: positionKey, requestId: requestOid, requestNumber, materialName: String(item.materialName ?? ""),