feat(ct): чтение и запись исключённых поставщиков в SP (#1)
CtService: loadExcludedOffers / replaceExcludedOffers — сначала новые строки, потом прежние в корзину, чтобы сбой записи не терял исключённых; дубли отсеивает чтение. removeCTPositions убирает и исключённые строки снятых позиций. Преобразования строк SP ↔ колонки и офферы формы — отдельно, с тестами. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
// Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable.
|
// Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable.
|
||||||
|
|
||||||
|
import { SupplierExclusion } from '../types/domain';
|
||||||
|
|
||||||
export interface ExtraSupplier {
|
export interface ExtraSupplier {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
spId?: number;
|
spId?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
exclusion?: SupplierExclusion;
|
||||||
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
|
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
|
||||||
offerId?: number;
|
offerId?: number;
|
||||||
bin?: string;
|
bin?: string;
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export interface Supplier {
|
|||||||
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
||||||
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
||||||
adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt'
|
adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt'
|
||||||
|
exclusion?: SupplierExclusion; // исключён из сравнения на Шаге 3 — колонка скрыта, цены сохранены
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщик исключён из сравнения (docs/adr/0001). Хранится в «Исключённые предложения».
|
||||||
|
export interface SupplierExclusion {
|
||||||
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента.
|
// Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента.
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { SupplierOffer } from '../types/domain';
|
||||||
|
import { IExcludedOfferItem, fromExcludedOfferItem, toExcludedOfferBody } from '../../../../shared/offers/excludedOffers';
|
||||||
|
import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData } from './excludedColumns';
|
||||||
|
|
||||||
|
const REASON = 'Дорого';
|
||||||
|
const USD_SP_ID = 3;
|
||||||
|
const currencyCodeOf = (valutaSpId?: number): string => (valutaSpId === USD_SP_ID ? 'USD' : 'KZT');
|
||||||
|
|
||||||
|
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
|
||||||
|
return {
|
||||||
|
id: 1, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
|
||||||
|
positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||||
|
price: 10, summa: 4500, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOffer(overrides: Partial<SupplierOffer>): SupplierOffer {
|
||||||
|
return {
|
||||||
|
id: '', supplierId: '259_278', positionId: 'pos-a', itemIndex: 0, supplierPosition: 'Кабель', unit: 'м',
|
||||||
|
quantity: 100, pricePerUnit: 10, totalPriceKzt: 450000, currencyCode: 'USD',
|
||||||
|
pricePerUnitKzt: 4500, totalWithExtraKzt: 450000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps: ExcludedSaveDeps = {
|
||||||
|
positionSpIdOf: id => ({ 'pos-a': 1476, 'pos-b': 1477 } as Record<string, number>)[id],
|
||||||
|
valutaSpIdOf: code => (code === 'USD' ? USD_SP_ID : undefined),
|
||||||
|
deliveryKmOf: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('excludedItemsToFormData', () => {
|
||||||
|
it('колонка с причиной и валютой, id колонки — как у обычных (поставщик_файл КП)', () => {
|
||||||
|
const { suppliers } = excludedItemsToFormData([makeItem({})], currencyCodeOf);
|
||||||
|
|
||||||
|
expect(suppliers).toEqual([{
|
||||||
|
id: '259_278', spId: 259, kpFileSpId: 278, name: 'ООО Альфа', hasOffer: true,
|
||||||
|
currency: 'USD', exclusion: { reason: REASON },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('офферы привязаны к колонке и SP Id позиции, цены — как при обычной загрузке из SP', () => {
|
||||||
|
const { offers } = excludedItemsToFormData([makeItem({ itemIndex: 1 })], currencyCodeOf);
|
||||||
|
|
||||||
|
expect(offers).toEqual([expect.objectContaining({
|
||||||
|
supplierId: '259_278', positionId: '1476', itemIndex: 1, pricePerUnit: 10, quantity: 100,
|
||||||
|
currencyCode: 'USD', pricePerUnitKzt: 4500, totalWithExtraKzt: 450000, offerId: '278',
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поставщик без цен — колонка есть, офферов нет', () => {
|
||||||
|
const { suppliers, offers } = excludedItemsToFormData([makeItem({ positionSpId: undefined })], currencyCodeOf);
|
||||||
|
|
||||||
|
expect(suppliers).toHaveLength(1);
|
||||||
|
expect(suppliers[0].currency).toBe('KZT');
|
||||||
|
expect(offers).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExcludedRecordsForSave', () => {
|
||||||
|
const column = { id: '259_278', exclusion: { reason: REASON }, supplierSpId: 259, offerFileSpId: 278 };
|
||||||
|
|
||||||
|
it('пишет офферы только своей колонки и только непустые (как у обычных поставщиков)', () => {
|
||||||
|
const records = buildExcludedRecordsForSave([column], [
|
||||||
|
makeOffer({ positionId: 'pos-a' }),
|
||||||
|
makeOffer({ positionId: 'pos-b', totalWithExtraKzt: 0 }),
|
||||||
|
makeOffer({ supplierId: 'другая_колонка' }),
|
||||||
|
], deps);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]).toMatchObject({
|
||||||
|
supplierSpId: 259, offerFileSpId: 278, reason: REASON, positionSpId: 1476,
|
||||||
|
price: 10, summa: 450000, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('позиция ещё не в SP — оффер пропускается, а не пишется без позиции (это был бы маркер)', () => {
|
||||||
|
const records = buildExcludedRecordsForSave([column], [
|
||||||
|
makeOffer({ positionId: 'pos-a' }),
|
||||||
|
makeOffer({ positionId: 'новая-позиция' }),
|
||||||
|
], deps);
|
||||||
|
|
||||||
|
expect(records.map(r => r.positionSpId)).toEqual([1476]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без цен — строка-маркер', () => {
|
||||||
|
const records = buildExcludedRecordsForSave([column], [], deps);
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]).toMatchObject({ supplierSpId: 259, reason: REASON });
|
||||||
|
expect(records[0].positionSpId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('колонку без SP Id поставщика не пишет', () => {
|
||||||
|
expect(buildExcludedRecordsForSave([{ ...column, supplierSpId: undefined }], [makeOffer({})], deps)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сохранение → SP → открытие возвращает ту же колонку с теми же ценами', () => {
|
||||||
|
const records = buildExcludedRecordsForSave([column], [makeOffer({ positionId: 'pos-a', itemIndex: 1 })], deps);
|
||||||
|
const fromSp = records.map((r, i) => fromExcludedOfferItem({
|
||||||
|
...toExcludedOfferBody(364, r), Id: i + 1, supplier: { Title: 'ООО Альфа' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { suppliers, offers } = excludedItemsToFormData(fromSp, currencyCodeOf);
|
||||||
|
|
||||||
|
expect(suppliers[0]).toMatchObject({ id: '259_278', exclusion: { reason: REASON }, currency: 'USD' });
|
||||||
|
expect(offers[0]).toMatchObject({ positionId: '1476', itemIndex: 1, pricePerUnit: 10, totalWithExtraKzt: 450000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Supplier, SupplierExclusion, SupplierOffer } from '../types/domain';
|
||||||
|
import {
|
||||||
|
IExcludedOfferItem,
|
||||||
|
IExcludedOfferRecord,
|
||||||
|
groupExcludedColumns,
|
||||||
|
toExcludedRecords,
|
||||||
|
} from '../../../../shared/offers/excludedOffers';
|
||||||
|
import { buildSupplierColumnId } from './supplierColumns';
|
||||||
|
|
||||||
|
// Исключённые поставщики СТ (docs/adr/0001) ↔ колонки и офферы формы.
|
||||||
|
|
||||||
|
export interface ExcludedFormData {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
offers: SupplierOffer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие СТ из SP: строки «Исключённых предложений» → колонки (с exclusion) и офферы, в том же
|
||||||
|
// виде, что buildColumnsFromSpOffers / buildOffersFromSpRecords для обычных поставщиков.
|
||||||
|
export function excludedItemsToFormData(
|
||||||
|
items: IExcludedOfferItem[],
|
||||||
|
currencyCodeOf: (valutaSpId?: number) => string,
|
||||||
|
): ExcludedFormData {
|
||||||
|
const suppliers: Supplier[] = [];
|
||||||
|
const offers: SupplierOffer[] = [];
|
||||||
|
for (const column of groupExcludedColumns(items)) {
|
||||||
|
const columnId = buildSupplierColumnId(column.supplierSpId, column.offerFileSpId);
|
||||||
|
suppliers.push({
|
||||||
|
id: columnId,
|
||||||
|
spId: column.supplierSpId,
|
||||||
|
kpFileSpId: column.offerFileSpId,
|
||||||
|
name: column.supplierName,
|
||||||
|
hasOffer: true,
|
||||||
|
currency: currencyCodeOf(column.offers[0]?.valutaSpId),
|
||||||
|
exclusion: { reason: column.reason },
|
||||||
|
});
|
||||||
|
column.offers.forEach(o => offers.push({
|
||||||
|
id: '',
|
||||||
|
supplierId: columnId,
|
||||||
|
positionId: String(o.positionSpId),
|
||||||
|
itemIndex: o.itemIndex,
|
||||||
|
supplierPosition: o.supplierPosition || undefined,
|
||||||
|
unit: o.unit,
|
||||||
|
pricePerUnit: o.price,
|
||||||
|
quantity: o.quantity,
|
||||||
|
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||||
|
pricePerUnitKzt: o.priceTenge || o.price,
|
||||||
|
totalPriceKzt: o.summaTenge ?? o.summa,
|
||||||
|
totalWithExtraKzt: o.summaTenge ?? o.summa,
|
||||||
|
offerId: column.offerFileSpId !== undefined ? String(column.offerFileSpId) : undefined,
|
||||||
|
deliveryDistanceKm: o.deliveryDistanceKm,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return { suppliers, offers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонка исключённого поставщика на момент сохранения: SP Id поставщика и файла КП уже известны
|
||||||
|
// (у добавленных на Шаге 3 — после их создания в SP).
|
||||||
|
export interface ExcludedColumnToSave {
|
||||||
|
id: string;
|
||||||
|
exclusion: SupplierExclusion;
|
||||||
|
supplierSpId?: number;
|
||||||
|
offerFileSpId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExcludedSaveDeps {
|
||||||
|
// SP Id позиции по id строки таблицы; undefined — позиция ещё не в SP, оффер не пишем.
|
||||||
|
positionSpIdOf: (positionId: string) => number | undefined;
|
||||||
|
valutaSpIdOf: (currencyCode: string) => number | undefined;
|
||||||
|
deliveryKmOf: (positionId: string, columnId: string) => number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Сохранить» Шага 3: колонки исключённых + их посчитанные офферы → строки для SP. Правила те же,
|
||||||
|
// что у обычных поставщиков: пустые ячейки (сумма 0) не пишем.
|
||||||
|
export function buildExcludedRecordsForSave(
|
||||||
|
columns: ExcludedColumnToSave[],
|
||||||
|
offers: SupplierOffer[],
|
||||||
|
deps: ExcludedSaveDeps,
|
||||||
|
): IExcludedOfferRecord[] {
|
||||||
|
const records: IExcludedOfferRecord[] = [];
|
||||||
|
for (const column of columns) {
|
||||||
|
// Без SP Id поставщика строку не записать (лукап) — такой колонки быть не должно: handleSave
|
||||||
|
// создаёт поставщиков Шага 3 в SP до записи офферов.
|
||||||
|
if (column.supplierSpId === undefined) continue;
|
||||||
|
const columnOffers = offers
|
||||||
|
.filter(o => o.supplierId === column.id && o.totalWithExtraKzt > 0)
|
||||||
|
.map(o => ({ offer: o, positionSpId: deps.positionSpIdOf(o.positionId) }))
|
||||||
|
.filter((x): x is { offer: SupplierOffer; positionSpId: number } => x.positionSpId !== undefined)
|
||||||
|
.map(({ offer, positionSpId }) => ({
|
||||||
|
positionSpId,
|
||||||
|
itemIndex: offer.itemIndex ?? 0,
|
||||||
|
supplierPosition: offer.supplierPosition ?? '',
|
||||||
|
unit: offer.unit ?? '',
|
||||||
|
quantity: offer.quantity,
|
||||||
|
price: offer.pricePerUnit,
|
||||||
|
summa: offer.totalPriceKzt,
|
||||||
|
valutaSpId: deps.valutaSpIdOf(offer.currencyCode),
|
||||||
|
priceTenge: offer.pricePerUnitKzt,
|
||||||
|
summaTenge: offer.totalWithExtraKzt,
|
||||||
|
deliveryDistanceKm: deps.deliveryKmOf(offer.positionId, column.id),
|
||||||
|
}));
|
||||||
|
records.push(...toExcludedRecords(
|
||||||
|
{ supplierSpId: column.supplierSpId, offerFileSpId: column.offerFileSpId, reason: column.exclusion.reason },
|
||||||
|
columnOffers,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ import {
|
|||||||
keepLatestOfferRecords,
|
keepLatestOfferRecords,
|
||||||
parseOfferItemIndex,
|
parseOfferItemIndex,
|
||||||
} from "../../../shared/offers/offerRecords";
|
} from "../../../shared/offers/offerRecords";
|
||||||
|
import {
|
||||||
|
EXCLUDED_OFFER_SELECT_FIELDS,
|
||||||
|
IExcludedOfferItem,
|
||||||
|
IExcludedOfferRecord,
|
||||||
|
fromExcludedOfferItem,
|
||||||
|
toExcludedOfferBody,
|
||||||
|
} from "../../../shared/offers/excludedOffers";
|
||||||
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||||
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
||||||
import { buildPositionKeys } from "../components/utils/positionKey";
|
import { buildPositionKeys } from "../components/utils/positionKey";
|
||||||
@@ -65,6 +72,9 @@ const LISTS = {
|
|||||||
SUPPLIERS: "Поставщики",
|
SUPPLIERS: "Поставщики",
|
||||||
NO_OFFERS: "Не получены КП",
|
NO_OFFERS: "Не получены КП",
|
||||||
OFFERS: "Оценка поставщиков",
|
OFFERS: "Оценка поставщиков",
|
||||||
|
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001) — отдельно от OFFERS,
|
||||||
|
// чтобы отчёт СТ, INCORE и потоки их не видели.
|
||||||
|
EXCLUDED_OFFERS: "Исключённые предложения",
|
||||||
CURRENCY_RATES: "Курс Валют",
|
CURRENCY_RATES: "Курс Валют",
|
||||||
LIBRARY_KP: "Коммерческие предложения",
|
LIBRARY_KP: "Коммерческие предложения",
|
||||||
CRITERIA: "Критерии",
|
CRITERIA: "Критерии",
|
||||||
@@ -283,25 +293,28 @@ export class CtService {
|
|||||||
if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину.
|
// Позиции, снятые при повторном сохранении шага 1: их предложения (в т.ч. исключённых
|
||||||
// Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков».
|
// поставщиков) и сами позиции — в корзину. Сначала предложения: без позиции они бы остались висеть.
|
||||||
async removeCTPositions(positionSpIds: number[]): Promise<void> {
|
async removeCTPositions(positionSpIds: number[]): Promise<void> {
|
||||||
if (positionSpIds.length === 0) return;
|
if (positionSpIds.length === 0) return;
|
||||||
const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
||||||
const res = await this.http.get(
|
const loadIds = async (listName: string): Promise<number[]> => {
|
||||||
`${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`,
|
const res = await this.http.get(
|
||||||
SPHttpClient.configurations.v1,
|
`${this._li(listName)}?$select=Id&$filter=${filter}&$top=5000`,
|
||||||
);
|
SPHttpClient.configurations.v1,
|
||||||
if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`);
|
);
|
||||||
const data = await res.json();
|
if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`);
|
||||||
const offerIds = ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id);
|
const data = await res.json();
|
||||||
|
return ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id);
|
||||||
|
};
|
||||||
// MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers.
|
// MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||||
const recycle = (listName: string) => async (id: number): Promise<void> => {
|
const recycle = (listName: string) => async (id: number): Promise<void> => {
|
||||||
await this.http
|
await this.http
|
||||||
.post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
.post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
};
|
};
|
||||||
await this._runChunked(offerIds, 20, recycle(LISTS.OFFERS));
|
await this._runChunked(await loadIds(LISTS.OFFERS), 20, recycle(LISTS.OFFERS));
|
||||||
|
await this._runChunked(await loadIds(LISTS.EXCLUDED_OFFERS), 20, recycle(LISTS.EXCLUDED_OFFERS));
|
||||||
await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS));
|
await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1361,6 +1374,40 @@ export class CtService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Исключённые предложения ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async loadExcludedOffers(ctId: string): Promise<IExcludedOfferItem[]> {
|
||||||
|
const url =
|
||||||
|
`${this._li(LISTS.EXCLUDED_OFFERS)}?$select=${EXCLUDED_OFFER_SELECT_FIELDS.join(",")}` +
|
||||||
|
`&$expand=supplier&$filter=CTId eq ${parseInt(ctId, 10)}&$top=5000`;
|
||||||
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) throw new Error(`Ошибка чтения исключённых поставщиков: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return ((data.value ?? []) as Array<Record<string, unknown>>).map(fromExcludedOfferItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приводит исключённых этой СТ к текущему Шагу 3. Сначала новые строки, потом прежние в корзину:
|
||||||
|
// упади запись посередине — прежние исключённые останутся, а дубли отсеет чтение
|
||||||
|
// (groupExcludedColumns). Вернувшиеся в сравнение поставщики уходят вместе с прежними строками.
|
||||||
|
async replaceExcludedOffers(ctId: string, records: IExcludedOfferRecord[]): Promise<void> {
|
||||||
|
const ctSpId = parseInt(ctId, 10);
|
||||||
|
const previousIds = (await this.loadExcludedOffers(ctId)).map((item) => item.id);
|
||||||
|
if (records.length === 0 && previousIds.length === 0) return;
|
||||||
|
|
||||||
|
await this._runChunked(records, 15, async (record) => {
|
||||||
|
const res = await this.http.post(this._li(LISTS.EXCLUDED_OFFERS), SPHttpClient.configurations.v1, {
|
||||||
|
headers: { "Content-Type": "application/json;odata=nometadata" },
|
||||||
|
body: JSON.stringify(toExcludedOfferBody(ctSpId, record)),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка сохранения исключённого поставщика: ${res.status}`);
|
||||||
|
});
|
||||||
|
await this._runChunked(previousIds, 20, async (id) => {
|
||||||
|
await this.http
|
||||||
|
.post(`${this._liById(LISTS.EXCLUDED_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getCurrentUserProfile(): Promise<{
|
async getCurrentUserProfile(): Promise<{
|
||||||
department: string;
|
department: string;
|
||||||
company: string;
|
company: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const REASON = 'Не прошёл проверку Adata';
|
|||||||
|
|
||||||
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
|
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
|
||||||
return {
|
return {
|
||||||
|
id: 1,
|
||||||
supplierSpId: 259,
|
supplierSpId: 259,
|
||||||
supplierName: 'ООО Альфа',
|
supplierName: 'ООО Альфа',
|
||||||
offerFileSpId: 278,
|
offerFileSpId: 278,
|
||||||
@@ -96,7 +97,7 @@ describe('fromExcludedOfferItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(record).toEqual({
|
expect(record).toEqual({
|
||||||
supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
|
id: 10, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
|
||||||
positionSpId: 1476, itemIndex: 1, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
positionSpId: 1476, itemIndex: 1, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||||
price: 450, summa: 45000, valutaSpId: 3, priceTenge: 450, summaTenge: 45000,
|
price: 450, summa: 45000, valutaSpId: 3, priceTenge: 450, summaTenge: 45000,
|
||||||
deliveryDistanceKm: undefined,
|
deliveryDistanceKm: undefined,
|
||||||
@@ -136,6 +137,19 @@ describe('groupExcludedColumns', () => {
|
|||||||
expect(columns[0].offers).toEqual([]);
|
expect(columns[0].offers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Сохранение упало между созданием новых строк и уборкой прежних — в SP обе версии.
|
||||||
|
it('из дублей оставляет последнюю строку, причина — из последнего сохранения', () => {
|
||||||
|
const columns = groupExcludedColumns([
|
||||||
|
makeItem({ id: 10, positionSpId: 1476, price: 400, reason: 'Дорого' }),
|
||||||
|
makeItem({ id: 11, positionSpId: 1477, price: 500, reason: 'Дорого' }),
|
||||||
|
makeItem({ id: 20, positionSpId: 1476, price: 450, reason: REASON }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(columns).toHaveLength(1);
|
||||||
|
expect(columns[0].reason).toBe(REASON);
|
||||||
|
expect(columns[0].offers.map(o => `${o.positionSpId}:${o.price}`)).toEqual(['1476:450', '1477:500']);
|
||||||
|
});
|
||||||
|
|
||||||
it('строки упорядочены по позиции и номеру строки комплекта', () => {
|
it('строки упорядочены по позиции и номеру строки комплекта', () => {
|
||||||
const columns = groupExcludedColumns([
|
const columns = groupExcludedColumns([
|
||||||
makeItem({ positionSpId: 1477, itemIndex: 0 }),
|
makeItem({ positionSpId: 1477, itemIndex: 0 }),
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ export interface IExcludedOfferRecord {
|
|||||||
deliveryDistanceKm?: number;
|
deliveryDistanceKm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строка, прочитанная из SP: дополнительно имя поставщика из лукапа.
|
// Строка, прочитанная из SP: дополнительно SP Id и имя поставщика из лукапа.
|
||||||
export interface IExcludedOfferItem extends IExcludedOfferRecord {
|
export interface IExcludedOfferItem extends IExcludedOfferRecord {
|
||||||
|
// Чем больше, тем позже создана — по нему отсеиваются дубли (см. groupExcludedColumns).
|
||||||
|
id: number;
|
||||||
supplierName: string;
|
supplierName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export function toExcludedOfferBody(ctSpId: number, record: IExcludedOfferRecord
|
|||||||
export function fromExcludedOfferItem(item: Record<string, unknown>): IExcludedOfferItem {
|
export function fromExcludedOfferItem(item: Record<string, unknown>): IExcludedOfferItem {
|
||||||
const supplier = item.supplier as Record<string, unknown> | undefined;
|
const supplier = item.supplier as Record<string, unknown> | undefined;
|
||||||
return {
|
return {
|
||||||
|
id: Number(item.Id ?? 0),
|
||||||
supplierSpId: Number(item.supplierId),
|
supplierSpId: Number(item.supplierId),
|
||||||
supplierName: String(supplier?.Title ?? ''),
|
supplierName: String(supplier?.Title ?? ''),
|
||||||
offerFileSpId: optionalNumber(item.offerFileId),
|
offerFileSpId: optionalNumber(item.offerFileId),
|
||||||
@@ -121,10 +124,25 @@ function columnKey(supplierSpId: number, offerFileSpId?: number): string {
|
|||||||
return `${supplierSpId}::${offerFileSpId ?? ''}`;
|
return `${supplierSpId}::${offerFileSpId ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Одна строка на (колонка, позиция, строка комплекта) — последняя по Id. Дубли остаются, если
|
||||||
|
// сохранение упало между созданием новых строк и уборкой прежних (сервис пишет в этом порядке,
|
||||||
|
// чтобы при сбое не потерять исключённых).
|
||||||
|
function keepLatestExcluded(items: IExcludedOfferItem[]): IExcludedOfferItem[] {
|
||||||
|
const latestByKey = new Map<string, IExcludedOfferItem>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${columnKey(item.supplierSpId, item.offerFileSpId)}::${item.positionSpId ?? ''}::${item.itemIndex}`;
|
||||||
|
const previous = latestByKey.get(key);
|
||||||
|
if (!previous || item.id > previous.id) latestByKey.set(key, item);
|
||||||
|
}
|
||||||
|
return Array.from(latestByKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
// Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения.
|
// Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения.
|
||||||
export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] {
|
export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] {
|
||||||
const columns = new Map<string, IExcludedSupplierColumn>();
|
const columns = new Map<string, IExcludedSupplierColumn>();
|
||||||
for (const item of items) {
|
// Новые строки первыми: причина колонки берётся из последнего сохранения.
|
||||||
|
const newestFirst = keepLatestExcluded(items).sort((a, b) => b.id - a.id);
|
||||||
|
for (const item of newestFirst) {
|
||||||
const key = columnKey(item.supplierSpId, item.offerFileSpId);
|
const key = columnKey(item.supplierSpId, item.offerFileSpId);
|
||||||
let column = columns.get(key);
|
let column = columns.get(key);
|
||||||
if (!column) {
|
if (!column) {
|
||||||
|
|||||||
Reference in New Issue
Block a user