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: '',
|
id: '',
|
||||||
supplierId: String(o.supplierSpId),
|
supplierId: String(o.supplierSpId),
|
||||||
positionId: String(o.positionSpId),
|
positionId: String(o.positionSpId),
|
||||||
|
itemIndex: o.itemIndex,
|
||||||
unit: o.unit,
|
unit: o.unit,
|
||||||
pricePerUnit: o.price,
|
pricePerUnit: o.price,
|
||||||
quantity: o.quantity,
|
quantity: o.quantity,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getRequestTypeIdByPageUrl,
|
getRequestTypeIdByPageUrl,
|
||||||
writeRoute,
|
writeRoute,
|
||||||
} from "../../../shared/approval";
|
} from "../../../shared/approval";
|
||||||
|
import { keepLatestOfferRecords, parseOfferItemIndex } from "../../../shared/offers/offerRecords";
|
||||||
|
|
||||||
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
||||||
function round2(value: number): number {
|
function round2(value: number): number {
|
||||||
@@ -779,6 +780,7 @@ export class CtService {
|
|||||||
priceTenge: number;
|
priceTenge: number;
|
||||||
summaTenge: number;
|
summaTenge: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
itemIndex: number;
|
||||||
valutaSpId?: number;
|
valutaSpId?: number;
|
||||||
deliveryDistanceKm?: number;
|
deliveryDistanceKm?: number;
|
||||||
offerFileSpId?: number;
|
offerFileSpId?: number;
|
||||||
@@ -792,18 +794,20 @@ export class CtService {
|
|||||||
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
|
// valutaId is selected directly; caller enriches currencyCode via loadCurrencyRatesByIds.
|
||||||
const url =
|
const url =
|
||||||
`${this._li(LISTS.OFFERS)}` +
|
`${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` +
|
`&$expand=supplier` +
|
||||||
`&$filter=${filter}` +
|
`&$filter=${filter}` +
|
||||||
`&$top=2000`;
|
`&$top=2000`;
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
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>) => {
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => {
|
||||||
const sup = item["supplier"] as Record<string, unknown> | undefined;
|
const sup = item["supplier"] as Record<string, unknown> | undefined;
|
||||||
const rawValutaId = item["valutaId"];
|
const rawValutaId = item["valutaId"];
|
||||||
const valutaSpId = rawValutaId ? Number(rawValutaId) : undefined;
|
const valutaSpId = rawValutaId ? Number(rawValutaId) : undefined;
|
||||||
return {
|
return {
|
||||||
|
id: Number(item["Id"]),
|
||||||
|
itemIndex: parseOfferItemIndex(String(item["Title"] ?? "")),
|
||||||
positionSpId: Number(item["positionId"]),
|
positionSpId: Number(item["positionId"]),
|
||||||
supplierSpId: Number(item["supplierId"]),
|
supplierSpId: Number(item["supplierId"]),
|
||||||
supplierName: String(sup?.["Title"] ?? ""),
|
supplierName: String(sup?.["Title"] ?? ""),
|
||||||
@@ -818,6 +822,7 @@ export class CtService {
|
|||||||
offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined,
|
offerFileSpId: item["offerFileId"] ? Number(item["offerFileId"]) : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return keepLatestOfferRecords(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadKpFilesForOffers(positionSpIds: number[]): Promise<
|
async loadKpFilesForOffers(positionSpIds: number[]): Promise<
|
||||||
@@ -1538,24 +1543,21 @@ export class CtService {
|
|||||||
const filter = positionSpIds
|
const filter = positionSpIds
|
||||||
.map((id) => `positionId eq ${id}`)
|
.map((id) => `positionId eq ${id}`)
|
||||||
.join(" or ");
|
.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);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
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>) => {
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
// Title format: "supId-posId" (old, index 0) or "supId-posId-N" (bundle row N)
|
id: Number(item.Id),
|
||||||
const parts = String(item.Title ?? "").split("-");
|
positionSpId: Number(item.positionId),
|
||||||
const itemIndex = parts.length >= 3 ? parseInt(parts[2], 10) || 0 : 0;
|
supplierSpId: Number(item.supplierId),
|
||||||
return {
|
price: Number(item.price ?? 0),
|
||||||
positionSpId: Number(item.positionId),
|
quantity: Number(item.quantity ?? 0),
|
||||||
supplierSpId: Number(item.supplierId),
|
supplierPosition: String(item.supplierPosition ?? ""),
|
||||||
price: Number(item.price ?? 0),
|
unit: String(item.unit ?? ""),
|
||||||
quantity: Number(item.quantity ?? 0),
|
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
|
||||||
supplierPosition: String(item.supplierPosition ?? ""),
|
}));
|
||||||
unit: String(item.unit ?? ""),
|
return keepLatestOfferRecords(records);
|
||||||
itemIndex,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveKpOffer(params: {
|
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