fix(ct): одна позиция не дублируется в таблице сравнения из-за повторных записей КП
Повторная обработка КП добавляет в «Оценка поставщиков» новые записи, а прежние удаляются только по «Сохранить» — в SP копились дубли (напр. СТ №89), и шаг 3 рисовал позицию несколькими одинаковыми под-строками. Загрузчики офферов теперь оставляют одну запись на (поставщик, позиция, itemIndex) — последнюю по Id. Загрузка по ссылке/без сессии заодно восстанавливает itemIndex из Title: раньше все строки комплекта шли как 0. Дедуп вынесен в shared/offers, чтобы переиспользовать в ОИД и КПС.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user