fix(ct): одна позиция не дублируется в таблице сравнения из-за повторных записей КП

Повторная обработка КП добавляет в «Оценка поставщиков» новые записи, а прежние удаляются
только по «Сохранить» — в SP копились дубли (напр. СТ №89), и шаг 3 рисовал позицию
несколькими одинаковыми под-строками. Загрузчики офферов теперь оставляют одну запись
на (поставщик, позиция, itemIndex) — последнюю по Id. Загрузка по ссылке/без сессии
заодно восстанавливает itemIndex из Title: раньше все строки комплекта шли как 0.

Дедуп вынесен в shared/offers, чтобы переиспользовать в ОИД и КПС.
This commit is contained in:
Тимур Городецкий
2026-09-30 15:05:08 +05:00
parent 5f5f0e7f81
commit 76ad9c6242
4 changed files with 111 additions and 17 deletions
@@ -127,6 +127,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
id: '',
supplierId: String(o.supplierSpId),
positionId: String(o.positionSpId),
itemIndex: o.itemIndex,
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
@@ -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<string, unknown>) => {
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => {
const sup = item["supplier"] as Record<string, unknown> | 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<string, unknown>) => {
// 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 {
const records = ((data.value ?? []) as Array<Record<string, unknown>>).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,
};
});
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
}));
return keepLatestOfferRecords(records);
}
async saveKpOffer(params: {
@@ -0,0 +1,59 @@
import { IOfferRecordKey, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
interface ITestRecord extends IOfferRecordKey {
price: number;
}
function makeRecord(overrides: Partial<ITestRecord>): 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);
});
});
@@ -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<T extends IOfferRecordKey>(records: T[]): T[] {
const latestByKey = new Map<string, T>();
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());
}