From 3907e5a60b28d3a4acfbbc0a8872a5491b3e1645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:35:32 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=C2=AB=D0=98=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=C2=BB=20=D0=B4=D0=BB=D1=8F=20=D1=82=D1=80?= =?UTF-8?q?=D1=91=D1=85=20=D1=84=D0=BE=D1=80=D0=BC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чистые функции без SharePoint: тело записи, разбор элемента, сборка колонок (поставщик + файл КП) и строка-маркер для поставщика без цен. Один модуль на СТ, ОИД и КПС, чтобы правила хранения не разошлись между формами. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared/offers/excludedOffers.test.ts | 194 ++++++++++++++++++ .../shared/offers/excludedOffers.ts | 159 ++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts create mode 100644 src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts new file mode 100644 index 0000000..f24ef7f --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts new file mode 100644 index 0000000..72dae26 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts @@ -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 { + const body: Record = { + 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): IExcludedOfferItem { + const supplier = item.supplier as Record | 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(); + 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, + offers: Array>, +): 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 })); +}