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