fix(ct): id позиции потребности — по её содержимому, а не по месту в ответе API

API /Requests/{oid}/positions отдаёт позиции без id и в разном порядке
(lineNumber — номер после сортировки по наименованию). id вида
GUID__номер указывал на разные позиции: на шаге 1 снимали одну — из СТ
удалялась другая.

Ключ — GUID потребности + хеш наименования, R66, АГСК, ед. изм.,
количества и вида СМР; одинаковые строки получают номер совпадения.
Метка h- отличает ключ от старых номерных — те ни с чем не сопоставятся.
This commit is contained in:
Тимур Городецкий
2026-10-22 09:20:58 +05:00
parent 5382459414
commit 62ebcb2f32
6 changed files with 143 additions and 9 deletions
@@ -61,7 +61,7 @@ describe('planStep1Save (СТ уже создана — повторное со
});
describe('countSelectedInRequest', () => {
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
it('считает выбранные позиции потребности без загрузки её позиций', () => {
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
@@ -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 (СТ открыта из реестра),
@@ -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 потребности
@@ -0,0 +1,70 @@
import { buildPositionKeys, PositionKeySource } from './positionKey';
function makeSource(materialName: string, overrides: Partial<PositionKeySource> = {}): 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`);
});
});
@@ -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<string, number>();
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}`;
});
}
@@ -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<string, unknown>): 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 ?? ""),