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', () => {
|
describe('countSelectedInRequest', () => {
|
||||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
|
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
|
||||||
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
||||||
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Position, Project, Request } from '../types/domain';
|
import { Position, Project, Request } from '../types/domain';
|
||||||
|
|
||||||
// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition).
|
// id позиции потребности начинается с `${id потребности}__` (см. utils/positionKey).
|
||||||
const POSITION_ID_SEPARATOR = '__';
|
const POSITION_ID_SEPARATOR = '__';
|
||||||
|
|
||||||
// Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра),
|
// Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Project {
|
|||||||
export interface Position {
|
export interface Position {
|
||||||
id: string;
|
id: string;
|
||||||
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
|
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
|
||||||
// Позиция потребности, из которой взята позиция СТ: `${GUID потребности}__${номер}` — id позиции
|
// Позиция потребности, из которой взята позиция СТ: её ключ (см. utils/positionKey) — id позиции
|
||||||
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
|
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
|
||||||
requestPositionId?: string;
|
requestPositionId?: string;
|
||||||
requestId: string; // GUID потребности
|
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";
|
} from "../../../shared/offers/offerRecords";
|
||||||
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||||
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
||||||
|
import { buildPositionKeys } from "../components/utils/positionKey";
|
||||||
import {
|
import {
|
||||||
NoOfferSupplierEntry,
|
NoOfferSupplierEntry,
|
||||||
NoOfferSupplierRecord,
|
NoOfferSupplierRecord,
|
||||||
@@ -209,8 +210,17 @@ export class CtService {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data: ApiPosition[] = await res.json();
|
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) =>
|
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 [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return (data.value ?? []).map((item: Record<string, unknown>): Position => {
|
return (data.value ?? []).map((item: Record<string, unknown>): Position => {
|
||||||
// `${GUID потребности}__${номер}`; у СТ, созданных до появления поля, пусто.
|
// Ключ позиции потребности (см. utils/positionKey); у СТ, созданных до появления поля, пусто.
|
||||||
const requestPositionId = String(item.requestPositionId ?? "");
|
const requestPositionId = String(item.requestPositionId ?? "");
|
||||||
return {
|
return {
|
||||||
// id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers).
|
// id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers).
|
||||||
@@ -1795,11 +1805,11 @@ export class CtService {
|
|||||||
|
|
||||||
private _mapPosition = (
|
private _mapPosition = (
|
||||||
item: ApiPosition,
|
item: ApiPosition,
|
||||||
requestOid = "",
|
positionKey: string,
|
||||||
requestNumber = "",
|
requestOid: string,
|
||||||
idx = 0,
|
requestNumber: string,
|
||||||
): Position => ({
|
): Position => ({
|
||||||
id: `${requestOid}__${idx}`,
|
id: positionKey,
|
||||||
requestId: requestOid,
|
requestId: requestOid,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
materialName: String(item.materialName ?? ""),
|
materialName: String(item.materialName ?? ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user