fix(ct): id позиции потребности — по её содержимому, а не по месту в ответе API
API /Requests/{oid}/positions отдаёт позиции без id и в разном порядке
(lineNumber — номер после сортировки по наименованию). id вида
GUID__номер указывал на разные позиции: на шаге 1 снимали одну — из СТ
удалялась другая.
Ключ — GUID потребности + хеш наименования, R66, АГСК, ед. изм.,
количества и вида СМР; одинаковые строки получают номер совпадения.
Метка h- отличает ключ от старых номерных — те ни с чем не сопоставятся.
This commit is contained in:
+1
-1
@@ -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 ?? ""),
|
||||
|
||||
Reference in New Issue
Block a user