fix(oid): повторная обработка КП поставщика заменяет его прежние предложения, а не дублирует

Шаг 2 ОИД писал предложения нового КП рядом с прежними записями поставщика. Шаг 3 всегда
грузит предложения из SP, поэтому позиция выглядела «комплектом» из одинаковых строк, а
автосохранение Шага 3 закрепляло это в SP. Теперь по повторному КП прежние записи поставщика
по тем же позициям уходят в корзину — после записи новых, чтобы при сбое не потерять данные.

Дедуп при загрузке, как в СТ, в ОИД не делаем: номер строки комплекта ОИД в Title не хранит,
и такой дедуп удалил бы настоящие строки комплекта.
This commit is contained in:
Тимур Городецкий
2026-10-01 10:59:11 +05:00
parent 1899a5ab38
commit 2bf50884c0
4 changed files with 82 additions and 2 deletions
@@ -146,7 +146,10 @@ export function useKpProcessing(service: OidService, formData: OIDFormData) {
};
});
if (offers.length > 0) {
service.saveOffers(offers).catch(() => undefined);
service.replaceSupplierOffers(supplierSpId, offers).catch((err) => {
// Не критично: Шаг 3 при сохранении перезаписывает предложения позиций целиком.
console.warn("[КП ОИД] не удалось записать предложения:", err);
});
}
}
@@ -25,6 +25,7 @@ import {
getRequestTypeIdByPageUrl,
writeRoute,
} from "../../../shared/approval";
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
@@ -786,6 +787,47 @@ export class OidService {
}
}
// Предложения нового КП поставщика (Шаг 2) заменяют его прежние по этим позициям. Раньше повторная
// обработка КП добавляла записи рядом с прежними, а Шаг 3 грузит всё из SP — позиция показывалась
// «комплектом» из одинаковых строк, и автосохранение Шага 3 закрепляло его в SP.
// Порядок «снимок → запись → удаление снимка»: упади запись — прежнее предложение останется.
async replaceSupplierOffers(supplierSpId: number, offers: SupplierOffer[]): Promise<void> {
if (offers.length === 0) return;
const positionSpIds = offers.map((o) => Number(o.positionId));
const filter =
`supplierId eq ${supplierSpId} and (` +
positionSpIds.map((id) => `positionId eq ${id}`).join(" or ") +
")";
const res = await this.sp.get(
`${listUrl(LISTS.OFFERS)}/items?$filter=${encodeURIComponent(filter)}&$select=Id,positionId,supplierId&$top=1000`,
SPHttpClient.configurations.v1,
);
const previous = res.ok
? ((await res.json()).value as Array<Record<string, unknown>>).map((item) => ({
id: Number(item.Id),
supplierSpId: Number(item.supplierId),
positionSpId: Number(item.positionId),
}))
: [];
await this.saveOffers(offers);
const staleIds = findSupplierOfferIds(previous, supplierSpId, positionSpIds);
if (staleIds.length === 0) return;
const results = await this.executeBatch(
staleIds.map((id) => ({
method: "POST" as const,
url: `${listUrl(LISTS.OFFERS)}/items(${id})/recycle()`,
})),
);
for (const r of results) {
if (r.ok || r.status === 404) continue;
// SP webhook bug: item IS recycled but event receiver throws FormatException
if (r.status === 500 && r.body.includes("FormatException")) continue;
throw new Error(`Ошибка удаления прежнего предложения: ${r.status}`);
}
}
async loadOffersForPositions(
positionSpIds: number[],
): Promise<SupplierOffer[]> {
@@ -1,4 +1,4 @@
import { IOfferRecordKey, findSupersededOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
import { IOfferRecordKey, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
interface ITestRecord extends IOfferRecordKey {
price: number;
@@ -94,3 +94,26 @@ describe('findSupersededOfferIds', () => {
expect(findSupersededOfferIds(existing, saved)).toEqual([]);
});
});
describe('findSupplierOfferIds', () => {
// ОИД: перед записью нового КП поставщика запоминаем его прежние записи по этим позициям,
// чтобы после записи убрать их. Номера строк комплекта ОИД в Title не хранит — берём все.
it('возвращает все записи поставщика по переданным позициям, включая строки комплекта', () => {
const records = [
makeRecord({ id: 314, supplierSpId: 18, positionSpId: 121 }),
makeRecord({ id: 315, supplierSpId: 18, positionSpId: 121 }),
makeRecord({ id: 316, supplierSpId: 18, positionSpId: 122 }),
];
expect(findSupplierOfferIds(records, 18, [121, 122])).toEqual([314, 315, 316]);
});
it('не трогает другие позиции и других поставщиков', () => {
const records = [
makeRecord({ id: 314, supplierSpId: 18, positionSpId: 999 }),
makeRecord({ id: 315, supplierSpId: 19, positionSpId: 121 }),
];
expect(findSupplierOfferIds(records, 18, [121])).toEqual([]);
});
});
@@ -31,6 +31,18 @@ export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]):
return Array.from(latestByKey.values());
}
// Все записи поставщика по указанным позициям — снимок «до» записи нового КП, чтобы после записи
// убрать прежние. Для форм, где SP Id новой записи заранее не известен (ОИД пишет пакетом).
export function findSupplierOfferIds(
records: Array<Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId'>>,
supplierSpId: number,
positionSpIds: number[],
): number[] {
return records
.filter(record => record.supplierSpId === supplierSpId && positionSpIds.includes(record.positionSpId))
.map(record => record.id);
}
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика по той же позиции,
// включая строки комплекта — новый КП поставщика заменяет его прежнее предложение целиком.
// Записи новее сохранённой не трогаем: их могла создать параллельная обработка.