feat: общий модуль строк «Исключённых предложений» для трёх форм (#1)
Чистые функции без SharePoint: тело записи, разбор элемента, сборка колонок (поставщик + файл КП) и строка-маркер для поставщика без цен. Один модуль на СТ, ОИД и КПС, чтобы правила хранения не разошлись между формами. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
EXCLUDED_OFFER_SELECT_FIELDS_OID,
|
||||
IExcludedOfferItem,
|
||||
buildExcludedOfferTitle,
|
||||
fromExcludedOfferItem,
|
||||
groupExcludedColumns,
|
||||
toExcludedOfferBody,
|
||||
toExcludedRecords,
|
||||
} from './excludedOffers';
|
||||
import { parseOfferItemIndex } from './offerRecords';
|
||||
|
||||
const CT_SP_ID = 364;
|
||||
const REASON = 'Не прошёл проверку Adata';
|
||||
|
||||
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
|
||||
return {
|
||||
supplierSpId: 259,
|
||||
supplierName: 'ООО Альфа',
|
||||
offerFileSpId: 278,
|
||||
reason: REASON,
|
||||
positionSpId: 1476,
|
||||
itemIndex: 0,
|
||||
supplierPosition: 'Кабель ВВГ 3х2,5',
|
||||
unit: 'м',
|
||||
quantity: 100,
|
||||
price: 450,
|
||||
summa: 45000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildExcludedOfferTitle', () => {
|
||||
it('в формате «Оценки поставщиков» — номер строки комплекта разбирается тем же парсером', () => {
|
||||
const title = buildExcludedOfferTitle(makeItem({ itemIndex: 2 }));
|
||||
|
||||
expect(title).toBe('259-1476-2');
|
||||
expect(parseOfferItemIndex(title)).toBe(2);
|
||||
});
|
||||
|
||||
it('первая строка комплекта — без номера', () => {
|
||||
expect(buildExcludedOfferTitle(makeItem({ itemIndex: 0 }))).toBe('259-1476');
|
||||
});
|
||||
|
||||
it('у маркера поставщика без цен позиция 0', () => {
|
||||
expect(buildExcludedOfferTitle(makeItem({ positionSpId: undefined }))).toBe('259-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toExcludedOfferBody', () => {
|
||||
it('пишет СТ, поставщика, причину и цены, округляя суммы до копеек', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({ price: 450.456, summa: 45045.6 }));
|
||||
|
||||
expect(body).toMatchObject({
|
||||
Title: '259-1476',
|
||||
CTId: CT_SP_ID,
|
||||
supplierId: 259,
|
||||
reason: REASON,
|
||||
positionId: 1476,
|
||||
offerFileId: 278,
|
||||
price: 450.46,
|
||||
summa: 45045.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('не пишет поля, которых нет у формы (ОИД без валюты и доставки) — иначе SP отклонит запрос', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({}));
|
||||
|
||||
expect(body).not.toHaveProperty('valutaId');
|
||||
expect(body).not.toHaveProperty('priceTenge');
|
||||
expect(body).not.toHaveProperty('summaTenge');
|
||||
expect(body).not.toHaveProperty('deliveryDistanceKm');
|
||||
});
|
||||
|
||||
it('у маркера нет позиции', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({ positionSpId: undefined }));
|
||||
|
||||
expect(body).not.toHaveProperty('positionId');
|
||||
});
|
||||
|
||||
it('валюту, цены в тенге и километраж пишет, когда они есть', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({
|
||||
valutaSpId: 3, priceTenge: 230000.004, summaTenge: 23000000, deliveryDistanceKm: 120,
|
||||
}));
|
||||
|
||||
expect(body).toMatchObject({ valutaId: 3, priceTenge: 230000, summaTenge: 23000000, deliveryDistanceKm: 120 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromExcludedOfferItem', () => {
|
||||
it('разбирает элемент SP REST, имя поставщика — из лукапа', () => {
|
||||
const record = fromExcludedOfferItem({
|
||||
Id: 10, Title: '259-1476-1', CTId: CT_SP_ID, supplierId: 259, supplier: { Title: 'ООО Альфа' },
|
||||
reason: REASON, positionId: 1476, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||
price: 450, summa: 45000, valutaId: 3, priceTenge: 450, summaTenge: 45000, offerFileId: 278,
|
||||
deliveryDistanceKm: null,
|
||||
});
|
||||
|
||||
expect(record).toEqual({
|
||||
supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
|
||||
positionSpId: 1476, itemIndex: 1, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||
price: 450, summa: 45000, valutaSpId: 3, priceTenge: 450, summaTenge: 45000,
|
||||
deliveryDistanceKm: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('пустые лукапы SP отдаёт как null — это «нет значения», а не 0', () => {
|
||||
const record = fromExcludedOfferItem({
|
||||
Title: '259-0', supplierId: 259, reason: REASON, positionId: null, valutaId: null, offerFileId: null,
|
||||
});
|
||||
|
||||
expect(record.positionSpId).toBeUndefined();
|
||||
expect(record.valutaSpId).toBeUndefined();
|
||||
expect(record.offerFileSpId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupExcludedColumns', () => {
|
||||
it('одна колонка на поставщика и файл КП — два КП одного поставщика остаются разными колонками', () => {
|
||||
const columns = groupExcludedColumns([
|
||||
makeItem({ offerFileSpId: 278, positionSpId: 1476 }),
|
||||
makeItem({ offerFileSpId: 278, positionSpId: 1477 }),
|
||||
makeItem({ offerFileSpId: 285, positionSpId: 1476, reason: 'Дорого' }),
|
||||
]);
|
||||
|
||||
expect(columns).toHaveLength(2);
|
||||
expect(columns[0]).toMatchObject({ supplierSpId: 259, offerFileSpId: 278, reason: REASON });
|
||||
expect(columns[0].offers).toHaveLength(2);
|
||||
expect(columns[1]).toMatchObject({ offerFileSpId: 285, reason: 'Дорого' });
|
||||
});
|
||||
|
||||
it('маркер даёт колонку без цен', () => {
|
||||
const columns = groupExcludedColumns([makeItem({ supplierSpId: 300, positionSpId: undefined })]);
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
expect(columns[0].supplierSpId).toBe(300);
|
||||
expect(columns[0].offers).toEqual([]);
|
||||
});
|
||||
|
||||
it('строки упорядочены по позиции и номеру строки комплекта', () => {
|
||||
const columns = groupExcludedColumns([
|
||||
makeItem({ positionSpId: 1477, itemIndex: 0 }),
|
||||
makeItem({ positionSpId: 1476, itemIndex: 1 }),
|
||||
makeItem({ positionSpId: 1476, itemIndex: 0 }),
|
||||
]);
|
||||
|
||||
expect(columns[0].offers.map(o => `${o.positionSpId}/${o.itemIndex}`)).toEqual(['1476/0', '1476/1', '1477/0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toExcludedRecords', () => {
|
||||
const column = { supplierSpId: 259, offerFileSpId: 278, reason: REASON };
|
||||
|
||||
it('проставляет каждой строке поставщика, файл КП и причину колонки', () => {
|
||||
const records = toExcludedRecords(column, [
|
||||
{ positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 1, price: 2, summa: 2 },
|
||||
]);
|
||||
|
||||
expect(records).toEqual([{
|
||||
supplierSpId: 259, offerFileSpId: 278, reason: REASON,
|
||||
positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 1, price: 2, summa: 2,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('поставщик без цен — одна строка-маркер, чтобы его можно было вернуть после перезагрузки', () => {
|
||||
const records = toExcludedRecords(column, []);
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].positionSpId).toBeUndefined();
|
||||
expect(records[0].reason).toBe(REASON);
|
||||
});
|
||||
|
||||
it('запись → чтение → группировка возвращает ту же колонку', () => {
|
||||
const records = toExcludedRecords(column, [
|
||||
{ positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 100, price: 450, summa: 45000 },
|
||||
]);
|
||||
// Имитируем SP: что записали телом, то и прочитали (+ имя из лукапа).
|
||||
const readBack = records.map(r => fromExcludedOfferItem({
|
||||
...toExcludedOfferBody(CT_SP_ID, r), supplier: { Title: 'ООО Альфа' },
|
||||
}));
|
||||
|
||||
const [restored] = groupExcludedColumns(readBack);
|
||||
|
||||
expect(restored).toMatchObject({ supplierSpId: 259, offerFileSpId: 278, reason: REASON, supplierName: 'ООО Альфа' });
|
||||
expect(restored.offers[0]).toMatchObject({ positionSpId: 1476, price: 450, quantity: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXCLUDED_OFFER_SELECT_FIELDS_OID', () => {
|
||||
it('без колонок, которых нет в списке ОИД', () => {
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).not.toContain('valutaId');
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).not.toContain('deliveryDistanceKm');
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).toContain('reason');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// Записи списков «Исключённые предложения» (СТ / ОИД / КПС): строки КП поставщика, которого
|
||||
// исключили из сравнения на Шаге 3. Хранятся отдельно от «Оценки поставщиков», чтобы отчёт СТ,
|
||||
// INCORE и потоки их не видели (docs/adr/0001). Колонки — как в «Оценке поставщиков» своей формы
|
||||
// плюс СТ и причина. Чистые функции: запросы к SharePoint — в сервисе каждой формы.
|
||||
|
||||
import { parseOfferItemIndex } from './offerRecords';
|
||||
|
||||
// Одна строка КП исключённого поставщика.
|
||||
export interface IExcludedOfferRecord {
|
||||
supplierSpId: number;
|
||||
// SP Id файла КП: у поставщика может быть несколько КП — каждое отдельная колонка.
|
||||
offerFileSpId?: number;
|
||||
reason: string;
|
||||
// Пусто у строки-маркера: поставщик исключён без единой цены, но колонка должна вернуться.
|
||||
positionSpId?: number;
|
||||
itemIndex: number;
|
||||
supplierPosition: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
summa: number;
|
||||
// Есть не во всех формах: у ОИД нет валюты, цен в тенге и доставки.
|
||||
valutaSpId?: number;
|
||||
priceTenge?: number;
|
||||
summaTenge?: number;
|
||||
deliveryDistanceKm?: number;
|
||||
}
|
||||
|
||||
// Строка, прочитанная из SP: дополнительно имя поставщика из лукапа.
|
||||
export interface IExcludedOfferItem extends IExcludedOfferRecord {
|
||||
supplierName: string;
|
||||
}
|
||||
|
||||
// Колонка исключённого поставщика, собранная из его строк.
|
||||
export interface IExcludedSupplierColumn {
|
||||
supplierSpId: number;
|
||||
supplierName: string;
|
||||
offerFileSpId?: number;
|
||||
reason: string;
|
||||
// Только строки с позицией — маркер сюда не попадает.
|
||||
offers: IExcludedOfferItem[];
|
||||
}
|
||||
|
||||
// Поля, которые читаются из SP. supplier/Title — через $expand=supplier.
|
||||
export const EXCLUDED_OFFER_SELECT_FIELDS = [
|
||||
'Id', 'Title', 'CTId', 'supplierId', 'supplier/Title', 'reason', 'positionId',
|
||||
'supplierPosition', 'unit', 'quantity', 'price', 'summa',
|
||||
'valutaId', 'priceTenge', 'summaTenge', 'offerFileId', 'deliveryDistanceKm',
|
||||
];
|
||||
|
||||
// Набор полей ОИД: в его списке нет валюты, цен в тенге и доставки — SP вернул бы 400 на $select.
|
||||
export const EXCLUDED_OFFER_SELECT_FIELDS_OID = EXCLUDED_OFFER_SELECT_FIELDS.filter(
|
||||
field => !['valutaId', 'priceTenge', 'summaTenge', 'deliveryDistanceKm'].includes(field),
|
||||
);
|
||||
|
||||
const MARKER_POSITION_IN_TITLE = 0;
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
// Title — в формате «Оценки поставщиков» («поставщик-позиция[-N]»), чтобы номер строки комплекта
|
||||
// разбирался тем же parseOfferItemIndex. У маркера позиция 0.
|
||||
export function buildExcludedOfferTitle(record: IExcludedOfferRecord): string {
|
||||
const base = `${record.supplierSpId}-${record.positionSpId ?? MARKER_POSITION_IN_TITLE}`;
|
||||
return record.itemIndex > 0 ? `${base}-${record.itemIndex}` : base;
|
||||
}
|
||||
|
||||
// Тело POST для SP. Необязательные поля пишутся, только если заданы: у ОИД этих колонок нет,
|
||||
// и SP отклонил бы запрос с неизвестным полем.
|
||||
export function toExcludedOfferBody(ctSpId: number, record: IExcludedOfferRecord): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
Title: buildExcludedOfferTitle(record),
|
||||
CTId: ctSpId,
|
||||
supplierId: record.supplierSpId,
|
||||
reason: record.reason,
|
||||
supplierPosition: record.supplierPosition,
|
||||
unit: record.unit,
|
||||
quantity: Number(record.quantity) || 0,
|
||||
price: round2(Number(record.price) || 0),
|
||||
summa: round2(Number(record.summa) || 0),
|
||||
};
|
||||
if (record.positionSpId !== undefined) body.positionId = record.positionSpId;
|
||||
if (record.offerFileSpId !== undefined) body.offerFileId = record.offerFileSpId;
|
||||
if (record.valutaSpId !== undefined) body.valutaId = record.valutaSpId;
|
||||
if (record.priceTenge !== undefined) body.priceTenge = round2(record.priceTenge);
|
||||
if (record.summaTenge !== undefined) body.summaTenge = round2(record.summaTenge);
|
||||
if (record.deliveryDistanceKm !== undefined) body.deliveryDistanceKm = record.deliveryDistanceKm;
|
||||
return body;
|
||||
}
|
||||
|
||||
// Элемент списка из SP REST (odata=nometadata) → запись. Пустой лукап SP отдаёт как null.
|
||||
export function fromExcludedOfferItem(item: Record<string, unknown>): IExcludedOfferItem {
|
||||
const supplier = item.supplier as Record<string, unknown> | undefined;
|
||||
return {
|
||||
supplierSpId: Number(item.supplierId),
|
||||
supplierName: String(supplier?.Title ?? ''),
|
||||
offerFileSpId: optionalNumber(item.offerFileId),
|
||||
reason: String(item.reason ?? ''),
|
||||
positionSpId: optionalNumber(item.positionId),
|
||||
itemIndex: parseOfferItemIndex(String(item.Title ?? '')),
|
||||
supplierPosition: String(item.supplierPosition ?? ''),
|
||||
unit: String(item.unit ?? ''),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
price: Number(item.price ?? 0),
|
||||
summa: Number(item.summa ?? 0),
|
||||
valutaSpId: optionalNumber(item.valutaId),
|
||||
priceTenge: optionalNumber(item.priceTenge),
|
||||
summaTenge: optionalNumber(item.summaTenge),
|
||||
deliveryDistanceKm: optionalNumber(item.deliveryDistanceKm),
|
||||
};
|
||||
}
|
||||
|
||||
function columnKey(supplierSpId: number, offerFileSpId?: number): string {
|
||||
return `${supplierSpId}::${offerFileSpId ?? ''}`;
|
||||
}
|
||||
|
||||
// Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения.
|
||||
export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] {
|
||||
const columns = new Map<string, IExcludedSupplierColumn>();
|
||||
for (const item of items) {
|
||||
const key = columnKey(item.supplierSpId, item.offerFileSpId);
|
||||
let column = columns.get(key);
|
||||
if (!column) {
|
||||
column = {
|
||||
supplierSpId: item.supplierSpId,
|
||||
supplierName: item.supplierName,
|
||||
offerFileSpId: item.offerFileSpId,
|
||||
reason: item.reason,
|
||||
offers: [],
|
||||
};
|
||||
columns.set(key, column);
|
||||
}
|
||||
if (item.positionSpId !== undefined) column.offers.push(item);
|
||||
}
|
||||
columns.forEach(column => column.offers.sort((a, b) =>
|
||||
(a.positionSpId ?? 0) - (b.positionSpId ?? 0) || a.itemIndex - b.itemIndex,
|
||||
));
|
||||
return Array.from(columns.values());
|
||||
}
|
||||
|
||||
// Колонка → строки для записи. Без цен — одна строка-маркер, иначе при открытии СТ поставщик
|
||||
// пропал бы из списка исключённых и вернуть его было бы нельзя.
|
||||
export function toExcludedRecords(
|
||||
column: Pick<IExcludedSupplierColumn, 'supplierSpId' | 'offerFileSpId' | 'reason'>,
|
||||
offers: Array<Omit<IExcludedOfferRecord, 'supplierSpId' | 'offerFileSpId' | 'reason'>>,
|
||||
): IExcludedOfferRecord[] {
|
||||
const base = { supplierSpId: column.supplierSpId, offerFileSpId: column.offerFileSpId, reason: column.reason };
|
||||
if (offers.length === 0) {
|
||||
return [{ ...base, itemIndex: 0, supplierPosition: '', unit: '', quantity: 0, price: 0, summa: 0 }];
|
||||
}
|
||||
return offers.map(offer => ({ ...offer, ...base }));
|
||||
}
|
||||
Reference in New Issue
Block a user