feat(ct): несколько КП одного поставщика — отдельные колонки в таблице сравнения
Колонка таблицы сравнения теперь «поставщик + файл КП» (id `${spId}_${kpFileSpId}`), а не только
поставщик. Два КП одного поставщика (напр. в KZT и USD) раньше схлопывались в одну колонку —
оставался последний файл, второе предложение пропадало. Теперь каждый КП — своя колонка со своей
валютой; записи в SP различаются по offerFileId: дедуп и чистка повторной обработки не трогают
записи другого КП того же поставщика, восстановление по ссылке раскладывает их по колонкам.
Старые колонки без файла (данные до изменения) работают как раньше.
Этап 1 из 4: выбора «заменить / добавить отдельно» пока нет — второй КП поставщика всегда
идёт отдельной колонкой.
This commit is contained in:
@@ -8,6 +8,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
||||
import styles from './CtCreateForm.module.scss';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
|
||||
@@ -85,17 +86,23 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
|
||||
const spCurrencyRates = loadedRates;
|
||||
|
||||
// Build unique suppliers from offers (used only as fallback when session has none)
|
||||
const supplierCurrencyMap = new Map<number, string>();
|
||||
rawOffers.forEach(o => {
|
||||
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
|
||||
});
|
||||
// Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько
|
||||
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
|
||||
supplierSpId: o.supplierSpId,
|
||||
supplierName: o.supplierName,
|
||||
offerFileSpId: o.offerFileSpId,
|
||||
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||
})));
|
||||
const supplierMap = new Map<number, string>();
|
||||
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
||||
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
|
||||
id: String(spId), spId, name, hasOffer: true as const,
|
||||
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
|
||||
}));
|
||||
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
||||
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
|
||||
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
||||
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
||||
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
||||
|
||||
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
|
||||
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
|
||||
@@ -106,26 +113,23 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
|
||||
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
|
||||
});
|
||||
const supReactIdBySpId = new Map<number, string>();
|
||||
formData.suppliers.forEach(s => {
|
||||
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
|
||||
});
|
||||
const step3DeliveryKm: Record<string, number> = {};
|
||||
rawOffers.forEach(o => {
|
||||
if (o.deliveryDistanceKm !== undefined) {
|
||||
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
|
||||
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId);
|
||||
const supKey = columnIdOf(o.supplierSpId, o.offerFileSpId);
|
||||
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
|
||||
}
|
||||
});
|
||||
|
||||
// Build offers in domain format, restoring offerId from session
|
||||
// Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
|
||||
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
|
||||
const offers = rawOffers.map(o => {
|
||||
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId) ?? o.offerFileSpId;
|
||||
const offerIdNum = o.offerFileSpId ?? offerIdBySupplierSpId.get(o.supplierSpId);
|
||||
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||
return {
|
||||
id: '',
|
||||
supplierId: String(o.supplierSpId),
|
||||
supplierId: columnIdOf(o.supplierSpId, o.offerFileSpId),
|
||||
positionId: String(o.positionSpId),
|
||||
itemIndex: o.itemIndex,
|
||||
unit: o.unit,
|
||||
|
||||
+4
-3
@@ -75,18 +75,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
|
||||
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
|
||||
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
|
||||
// Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах.
|
||||
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
|
||||
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
||||
if (!uploaded?.supplierName) return;
|
||||
onSave(applySupplierCurrency(
|
||||
formData.suppliers ?? [],
|
||||
formData.offers ?? [],
|
||||
uploaded.supplierName,
|
||||
uploaded,
|
||||
currencyCode,
|
||||
currencyRates,
|
||||
));
|
||||
kp.kpFiles
|
||||
.filter(f => f.supplierName === uploaded.supplierName)
|
||||
.filter(f => f.spId === fileSpId)
|
||||
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
||||
};
|
||||
|
||||
@@ -152,7 +153,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
files={formData.uploadedKpFiles}
|
||||
service={service}
|
||||
currencyOptions={currencyOptions}
|
||||
getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)}
|
||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||
onCurrencyChange={handleUploadedCurrencyChange}
|
||||
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
|
||||
disabled={isNotificationLink || isReadOnly}
|
||||
|
||||
+4
-3
@@ -2,13 +2,14 @@ import * as React from 'react';
|
||||
import { IconButton } from '@tnsu/ui-kit';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { KpFileRef } from './kpCurrency';
|
||||
import styles from './Step2CommercialOffers.module.scss';
|
||||
|
||||
interface Props {
|
||||
files: CTFormData['uploadedKpFiles'];
|
||||
service: CtService;
|
||||
currencyOptions: string[];
|
||||
getCurrency: (supplierName: string | undefined) => string;
|
||||
getCurrency: (file: KpFileRef) => string;
|
||||
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
||||
onRemove: (spId: number) => void;
|
||||
disabled?: boolean;
|
||||
@@ -40,12 +41,12 @@ export const UploadedKpList: React.FC<Props> = ({
|
||||
{f.name}
|
||||
</a>
|
||||
{disabled ? (
|
||||
<span className={styles.kpCurrencyCode}>{getCurrency(f.supplierName)}</span>
|
||||
<span className={styles.kpCurrencyCode}>{getCurrency(f)}</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className={styles.kpCurrencySelect}
|
||||
value={getCurrency(f.supplierName)}
|
||||
value={getCurrency(f)}
|
||||
onChange={e => onCurrencyChange(f.spId, e.target.value)}
|
||||
// Валюта хранится на поставщике — без него менять нечего.
|
||||
disabled={!f.supplierName}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
|
||||
const USD: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 440 };
|
||||
|
||||
const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
|
||||
const USD_COLUMN: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
|
||||
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала»', hasOffer: true, currency: 'KZT' };
|
||||
|
||||
function makeOffer(supplierId: string): SupplierOffer {
|
||||
return {
|
||||
id: '', supplierId, positionId: 'p1', pricePerUnit: 10, quantity: 2, currencyCode: 'KZT',
|
||||
pricePerUnitKzt: 10, totalPriceKzt: 20, totalWithExtraKzt: 20,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applySupplierCurrency', () => {
|
||||
// Два КП одного поставщика: смена валюты одного файла не должна пересчитать цены другого.
|
||||
it('меняет валюту только у колонки этого файла КП', () => {
|
||||
const { suppliers, offers } = applySupplierCurrency(
|
||||
[KZT_COLUMN, USD_COLUMN],
|
||||
[makeOffer('263_277'), makeOffer('263_290')],
|
||||
{ spId: 290, supplierName: 'ТОО «Арлан»' },
|
||||
'USD',
|
||||
[USD],
|
||||
);
|
||||
|
||||
expect(suppliers.map(s => s.currency)).toEqual(['KZT', 'USD']);
|
||||
expect(offers[0].pricePerUnitKzt).toBe(10);
|
||||
expect(offers[1]).toMatchObject({ currencyCode: 'USD', pricePerUnitKzt: 4400, totalPriceKzt: 8800 });
|
||||
});
|
||||
|
||||
it('старая колонка без файла находится по имени поставщика', () => {
|
||||
const { suppliers } = applySupplierCurrency([LEGACY], [], { spId: 278, supplierName: 'ТОО «Кала»' }, 'USD', [USD]);
|
||||
|
||||
expect(suppliers[0].currency).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSupplierCurrency', () => {
|
||||
it('валюта колонки этого файла КП', () => {
|
||||
const columns = [KZT_COLUMN, { ...USD_COLUMN, currency: 'USD' }];
|
||||
|
||||
expect(getSupplierCurrency(columns, { spId: 290, supplierName: 'ТОО «Арлан»' })).toBe('USD');
|
||||
expect(getSupplierCurrency(columns, { spId: 277, supplierName: 'ТОО «Арлан»' })).toBe('KZT');
|
||||
});
|
||||
});
|
||||
+21
-10
@@ -2,28 +2,40 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
|
||||
const BASE_CURRENCY = 'KZT';
|
||||
|
||||
// Валюта хранится на поставщике (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
|
||||
// Файл из списка «Загруженные КП», чью валюту меняют/читают.
|
||||
export interface KpFileRef {
|
||||
spId: number;
|
||||
supplierName?: string;
|
||||
}
|
||||
|
||||
// Колонки этого файла КП. У поставщика может быть несколько КП (напр. KZT и USD) — валюта у каждой
|
||||
// колонки своя. Старые колонки без файла (kpFileSpId) ищем по имени поставщика, как раньше.
|
||||
function findFileColumns(suppliers: Supplier[], file: KpFileRef): Supplier[] {
|
||||
const byFile = suppliers.filter(s => s.hasOffer && s.kpFileSpId === file.spId);
|
||||
if (byFile.length > 0) return byFile;
|
||||
return suppliers.filter(s => s.hasOffer && s.kpFileSpId === undefined && s.name === file.supplierName);
|
||||
}
|
||||
|
||||
// Валюта хранится на колонке поставщика (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
|
||||
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
|
||||
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
|
||||
export function applySupplierCurrency(
|
||||
suppliers: Supplier[],
|
||||
offers: SupplierOffer[],
|
||||
supplierName: string,
|
||||
file: KpFileRef,
|
||||
currencyCode: string,
|
||||
currencyRates: CurrencyRate[],
|
||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||
const supplierIds = new Set(
|
||||
suppliers.filter(s => s.hasOffer && s.name === supplierName).map(s => s.id),
|
||||
);
|
||||
const columnIds = new Set(findFileColumns(suppliers, file).map(s => s.id));
|
||||
const rate = currencyCode === BASE_CURRENCY
|
||||
? 1
|
||||
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
|
||||
|
||||
const nextSuppliers = suppliers.map(s =>
|
||||
supplierIds.has(s.id) ? { ...s, currency: currencyCode } : s,
|
||||
columnIds.has(s.id) ? { ...s, currency: currencyCode } : s,
|
||||
);
|
||||
const nextOffers = offers.map(o => {
|
||||
if (!supplierIds.has(o.supplierId)) return o;
|
||||
if (!columnIds.has(o.supplierId)) return o;
|
||||
const pricePerUnitKzt = o.pricePerUnit * rate;
|
||||
const totalPriceKzt = pricePerUnitKzt * o.quantity;
|
||||
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
|
||||
@@ -32,7 +44,6 @@ export function applySupplierCurrency(
|
||||
return { suppliers: nextSuppliers, offers: nextOffers };
|
||||
}
|
||||
|
||||
export function getSupplierCurrency(suppliers: Supplier[], supplierName: string | undefined): string {
|
||||
const supplier = suppliers.find(s => s.hasOffer && s.name === supplierName);
|
||||
return supplier?.currency ?? BASE_CURRENCY;
|
||||
export function getSupplierCurrency(suppliers: Supplier[], file: KpFileRef): string {
|
||||
return findFileColumns(suppliers, file)[0]?.currency ?? BASE_CURRENCY;
|
||||
}
|
||||
|
||||
+19
-33
@@ -28,11 +28,11 @@ describe('buildKpSuppliersAndOffers', () => {
|
||||
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
|
||||
|
||||
expect(suppliers).toEqual([
|
||||
{ id: '10', spId: 10, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
|
||||
{ id: '10_100', spId: 10, kpFileSpId: 100, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
|
||||
]);
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0]).toMatchObject({
|
||||
supplierId: '10',
|
||||
supplierId: '10_100',
|
||||
positionId: 'pos_1',
|
||||
supplierPosition: 'Кабель',
|
||||
pricePerUnit: 200,
|
||||
@@ -43,41 +43,27 @@ describe('buildKpSuppliersAndOffers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Регресс 5f5f0e7: два КП одного поставщика давали дубль колонки и задвоенные строки позиций.
|
||||
it('из нескольких КП одного поставщика берёт только последний', () => {
|
||||
const first = makeKpFile({ localId: 'a', spId: 100 });
|
||||
const second = makeKpFile({
|
||||
// Два КП одного поставщика (напр. KZT и USD) — два отдельных предложения: по колонке на файл.
|
||||
// Раньше брался только последний файл (5f5f0e7), и второе предложение пропадало.
|
||||
it('каждый КП поставщика — отдельная колонка со своими предложениями', () => {
|
||||
const kzt = makeKpFile({ localId: 'a', spId: 100 });
|
||||
const usd = makeKpFile({
|
||||
localId: 'b',
|
||||
spId: 101,
|
||||
flowResults: [{ positionId: 'pos_1', flowResult: { price: 300, quantity: 5 } }],
|
||||
currencyCode: 'USD',
|
||||
flowResults: [{ positionId: 'pos_1', flowResult: { price: 3, quantity: 5 } }],
|
||||
});
|
||||
|
||||
const { suppliers, offers } = buildKpSuppliersAndOffers([first, second], []);
|
||||
const { suppliers, offers } = buildKpSuppliersAndOffers([kzt, usd], [USD_RATE]);
|
||||
|
||||
expect(suppliers).toHaveLength(1);
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0].offerId).toBe('101');
|
||||
expect(offers[0].pricePerUnit).toBe(300);
|
||||
});
|
||||
|
||||
it('не вытесняет обработанный КП тем, что ещё в процессе', () => {
|
||||
const done = makeKpFile({ localId: 'a', spId: 100, status: 'done' });
|
||||
const inProgress = makeKpFile({ localId: 'b', spId: 101, status: 'flowing' });
|
||||
|
||||
const { offers } = buildKpSuppliersAndOffers([done, inProgress], []);
|
||||
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0].offerId).toBe('100');
|
||||
});
|
||||
|
||||
it('заменяет КП в процессе на обработанный', () => {
|
||||
const inProgress = makeKpFile({ localId: 'a', spId: 100, status: 'flowing' });
|
||||
const done = makeKpFile({ localId: 'b', spId: 101, status: 'done' });
|
||||
|
||||
const { offers } = buildKpSuppliersAndOffers([inProgress, done], []);
|
||||
|
||||
expect(offers).toHaveLength(1);
|
||||
expect(offers[0].offerId).toBe('101');
|
||||
expect(suppliers.map(s => ({ id: s.id, kpFileSpId: s.kpFileSpId, currency: s.currency }))).toEqual([
|
||||
{ id: '10_100', kpFileSpId: 100, currency: 'KZT' },
|
||||
{ id: '10_101', kpFileSpId: 101, currency: 'USD' },
|
||||
]);
|
||||
expect(offers.map(o => ({ supplierId: o.supplierId, offerId: o.offerId, pricePerUnit: o.pricePerUnit }))).toEqual([
|
||||
{ supplierId: '10_100', offerId: '100', pricePerUnit: 200 },
|
||||
{ supplierId: '10_101', offerId: '101', pricePerUnit: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('разных поставщиков оставляет отдельными колонками', () => {
|
||||
@@ -86,7 +72,7 @@ describe('buildKpSuppliersAndOffers', () => {
|
||||
|
||||
const { suppliers } = buildKpSuppliersAndOffers([first, second], []);
|
||||
|
||||
expect(suppliers.map(s => s.id)).toEqual(['10', '20']);
|
||||
expect(suppliers.map(s => s.id)).toEqual(['10_100', '20_100']);
|
||||
});
|
||||
|
||||
it('пропускает файл, у которого поток ещё не определил поставщика', () => {
|
||||
|
||||
+18
-11
@@ -2,7 +2,7 @@ import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { KpFile, NoOfferEntry } from './kpTypes';
|
||||
import { pickLatestKpFilePerSupplier } from '../../../../shared/offers/kpFiles';
|
||||
import { buildSupplierColumnId } from '../utils/supplierColumns';
|
||||
|
||||
export interface PersistStep2Args {
|
||||
service: CtService;
|
||||
@@ -12,18 +12,23 @@ export interface PersistStep2Args {
|
||||
noOfferSuppliers: NoOfferEntry[];
|
||||
}
|
||||
|
||||
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
|
||||
// Строит колонки поставщиков и их офферы из результатов потока. Вынесено из persistStep2,
|
||||
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
|
||||
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
|
||||
// Колонка — на каждый файл КП: несколько КП одного поставщика (напр. KZT и USD) — отдельные
|
||||
// предложения. Раньше брался только последний файл поставщика, и второе предложение пропадало.
|
||||
export function buildKpSuppliersAndOffers(
|
||||
allKpFiles: KpFile[],
|
||||
currencyRates: CurrencyRate[],
|
||||
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
|
||||
const kpFiles = pickLatestKpFilePerSupplier(allKpFiles);
|
||||
const kpFiles = allKpFiles.filter(f => f.supplierName);
|
||||
const columnIdOf = (f: KpFile): string =>
|
||||
f.supplierSpId ? buildSupplierColumnId(f.supplierSpId, f.spId) : f.localId;
|
||||
const suppliers: Supplier[] = kpFiles
|
||||
.map(f => ({
|
||||
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
|
||||
id: columnIdOf(f),
|
||||
spId: f.supplierSpId,
|
||||
kpFileSpId: f.spId,
|
||||
name: f.supplierName as string,
|
||||
hasOffer: true,
|
||||
currency: f.currencyCode,
|
||||
@@ -43,7 +48,7 @@ export function buildKpSuppliersAndOffers(
|
||||
const totalPriceKzt = pricePerUnitKzt * quantity;
|
||||
offers.push({
|
||||
id: '',
|
||||
supplierId: String(f.supplierSpId),
|
||||
supplierId: columnIdOf(f),
|
||||
positionId: fr.positionId,
|
||||
supplierPosition: fr.flowResult.position,
|
||||
unit: fr.flowResult.unit,
|
||||
@@ -87,23 +92,25 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
|
||||
// before proceeding to Step 3.
|
||||
if (newOffers.length > 0) {
|
||||
// id колонки («поставщик_файл») в SP не пишем — туда идут SP Id поставщика и позиции.
|
||||
const offersForSP = newOffers
|
||||
.map(o => {
|
||||
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
|
||||
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null;
|
||||
const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId;
|
||||
if (pos?.spId === undefined || supplierSpId === undefined) return null;
|
||||
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
|
||||
})
|
||||
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10)));
|
||||
.filter((o): o is SupplierOffer => o !== null);
|
||||
if (offersForSP.length > 0) {
|
||||
await service.saveOffers(offersForSP, savedRates);
|
||||
}
|
||||
}
|
||||
|
||||
// Мержим с поставщиками из предыдущих сессий на шаге 2
|
||||
// (повторная загрузка КП того же поставщика — заменяем по имени)
|
||||
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
|
||||
// Мержим с колонками из предыдущих сессий на шаге 2 — по id колонки (поставщик + файл КП):
|
||||
// другой КП того же поставщика остаётся отдельной колонкой.
|
||||
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
|
||||
const mergedSuppliersWithOffer: Supplier[] = [
|
||||
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
|
||||
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierIds.has(s.id)),
|
||||
...newSuppliersWithOffer,
|
||||
];
|
||||
const mergedOffers: SupplierOffer[] = [
|
||||
|
||||
+3
-2
@@ -56,8 +56,9 @@ export function useKpProcessing(
|
||||
|
||||
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
|
||||
if (kpSuppliers.length > 0) {
|
||||
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем.
|
||||
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name));
|
||||
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
|
||||
// поставщика — отдельное предложение, своя колонка.
|
||||
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
|
||||
patch.suppliers = [...others, ...kpSuppliers];
|
||||
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
|
||||
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
|
||||
|
||||
+21
-8
@@ -6,6 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
|
||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
||||
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
|
||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||
import { findColumnForRecord } from '../utils/supplierColumns';
|
||||
|
||||
let extraIdCounter = 0;
|
||||
|
||||
@@ -138,9 +139,13 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
service.loadCriteria(supplierSpIds)
|
||||
.then(rawCriteria => {
|
||||
const spMap: Record<string, CriteriaEntry> = {};
|
||||
// Критерии в SP — на поставщика, а не на КП: у всех колонок поставщика (несколько КП) они общие.
|
||||
rawCriteria.forEach(c => {
|
||||
const sup = suppliers.find(s => s.spId === c.supplierSpId);
|
||||
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
|
||||
suppliers
|
||||
.filter(s => s.spId === c.supplierSpId)
|
||||
.forEach(sup => {
|
||||
spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
|
||||
});
|
||||
});
|
||||
// Merge SP data with any user edits from formData — user edits take priority
|
||||
setCriteriaMap(prev => ({ ...spMap, ...prev }));
|
||||
@@ -165,7 +170,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const newEdits: EditableOffers = {};
|
||||
rawOffers.forEach(raw => {
|
||||
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
|
||||
const sup = suppliers.find(s => s.spId === raw.supplierSpId);
|
||||
const sup = findColumnForRecord(suppliers, raw.supplierSpId, raw.offerFileSpId);
|
||||
if (!pos || !sup) return;
|
||||
const key = buildOfferKey(sup.id, pos.id);
|
||||
const item: CellEdit = {
|
||||
@@ -221,7 +226,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const merged = currentOffers.map((o: SupplierOffer) => {
|
||||
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
|
||||
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
|
||||
const supSpId = sup?.spId !== undefined ? String(sup.spId) : undefined;
|
||||
// Сверка по SP Id поставщика — только для старых колонок без файла КП: у поставщика с
|
||||
// несколькими КП она подхватила бы оффер чужой колонки.
|
||||
const supSpId = sup?.spId !== undefined && sup.kpFileSpId === undefined ? String(sup.spId) : undefined;
|
||||
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
|
||||
const prev = prevOffers.find((p: SupplierOffer) =>
|
||||
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
|
||||
@@ -753,16 +760,22 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const posSpId = extraPosSpIds[o.positionId];
|
||||
const existingSup = suppliers.find(s => s.id === o.supplierId);
|
||||
const existingPos = selectedPositions.find(p => p.id === o.positionId);
|
||||
const supSpIdStr = existingSup?.spId !== undefined ? String(existingSup.spId) : undefined;
|
||||
// Сверка по SP Id поставщика — только для старых колонок без файла КП (см. beforeunload выше).
|
||||
const supSpIdStr = existingSup?.spId !== undefined && existingSup.kpFileSpId === undefined
|
||||
? String(existingSup.spId)
|
||||
: undefined;
|
||||
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
|
||||
const originalOffer = offers.find(orig =>
|
||||
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
|
||||
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
|
||||
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
|
||||
);
|
||||
const kpFileOfferId = existingSup
|
||||
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
|
||||
: undefined;
|
||||
// Файл КП колонки известен напрямую; поиск по имени поставщика — только для старых колонок
|
||||
// (у поставщика с несколькими КП он отдал бы обеим колонкам первый файл).
|
||||
const kpFileOfferId = existingSup?.kpFileSpId
|
||||
?? (existingSup
|
||||
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
|
||||
: undefined);
|
||||
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
|
||||
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
|
||||
return {
|
||||
|
||||
@@ -57,8 +57,9 @@ export type CTStatus =
|
||||
| "rejected";
|
||||
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
|
||||
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
||||
kpFileSpId?: number; // SP Id файла КП этой колонки — у поставщика может быть несколько КП (напр. KZT и USD)
|
||||
name: string;
|
||||
inn?: string; // UI only — not stored in SP
|
||||
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './supplierColumns';
|
||||
import { Supplier } from '../types/domain';
|
||||
|
||||
describe('buildSupplierColumnId', () => {
|
||||
it('колонка = поставщик + файл КП', () => {
|
||||
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
||||
});
|
||||
|
||||
it('без файла (старые данные) — только поставщик', () => {
|
||||
expect(buildSupplierColumnId(263)).toBe('263');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findColumnForRecord', () => {
|
||||
const KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||
const USD: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала Саплай»', hasOffer: true };
|
||||
|
||||
// Два КП одного поставщика: запись из SP должна попасть в колонку своего файла, а не в первую.
|
||||
it('находит колонку по поставщику и файлу КП', () => {
|
||||
expect(findColumnForRecord([KZT, USD], 263, 290)).toBe(USD);
|
||||
});
|
||||
|
||||
it('старая колонка без файла принимает запись поставщика', () => {
|
||||
expect(findColumnForRecord([LEGACY], 264, 278)).toBe(LEGACY);
|
||||
});
|
||||
|
||||
it('не отдаёт запись чужого файла в колонку другого КП', () => {
|
||||
expect(findColumnForRecord([KZT], 263, 290)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildColumnsFromSpOffers (открытие по ссылке / без сессии)', () => {
|
||||
const rows = [
|
||||
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
|
||||
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 277, currencyCode: 'KZT' },
|
||||
{ supplierSpId: 263, supplierName: 'ТОО «Арлан Офис Снаб»', offerFileSpId: 290, currencyCode: 'USD' },
|
||||
{ supplierSpId: 264, supplierName: 'ТОО «Кала Саплай»', currencyCode: 'KZT' },
|
||||
];
|
||||
|
||||
it('одна колонка на каждый КП поставщика, со своей валютой', () => {
|
||||
expect(buildColumnsFromSpOffers(rows)).toEqual([
|
||||
{ id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'KZT' },
|
||||
{ id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true, currency: 'USD' },
|
||||
{ id: '264', spId: 264, kpFileSpId: undefined, name: 'ТОО «Кала Саплай»', hasOffer: true, currency: 'KZT' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Supplier } from '../types/domain';
|
||||
|
||||
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
||||
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
||||
// разделителем в ключах ячеек (`${supplierId}::${positionId}`, deliveryKm и т.д.).
|
||||
export function buildSupplierColumnId(supplierSpId: number, kpFileSpId?: number): string {
|
||||
return kpFileSpId !== undefined ? `${supplierSpId}_${kpFileSpId}` : String(supplierSpId);
|
||||
}
|
||||
|
||||
// Колонка для записи «Оценка поставщиков»: сначала колонка этого же файла КП, иначе — старая
|
||||
// колонка поставщика без файла (данные до появления нескольких КП у поставщика).
|
||||
export function findColumnForRecord(
|
||||
columns: Supplier[],
|
||||
supplierSpId: number,
|
||||
offerFileSpId?: number,
|
||||
): Supplier | undefined {
|
||||
const sameFile = columns.find(c => c.spId === supplierSpId && c.kpFileSpId !== undefined && c.kpFileSpId === offerFileSpId);
|
||||
return sameFile ?? columns.find(c => c.spId === supplierSpId && c.kpFileSpId === undefined);
|
||||
}
|
||||
|
||||
// Запись «Оценка поставщиков», из которой восстанавливается колонка (открытие по ссылке / без сессии).
|
||||
export interface SpOfferColumnSource {
|
||||
supplierSpId: number;
|
||||
supplierName: string;
|
||||
offerFileSpId?: number;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
// Одна колонка на каждую пару (поставщик, файл КП), в порядке первого появления.
|
||||
export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Supplier[] {
|
||||
const columnsById = new Map<string, Supplier>();
|
||||
records.forEach(r => {
|
||||
const id = buildSupplierColumnId(r.supplierSpId, r.offerFileSpId);
|
||||
if (columnsById.has(id)) return;
|
||||
columnsById.set(id, {
|
||||
id,
|
||||
spId: r.supplierSpId,
|
||||
kpFileSpId: r.offerFileSpId,
|
||||
name: r.supplierName,
|
||||
hasOffer: true,
|
||||
currency: r.currencyCode,
|
||||
});
|
||||
});
|
||||
return Array.from(columnsById.values());
|
||||
}
|
||||
@@ -854,13 +854,14 @@ export class CtService {
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
|
||||
// По одному элементу на файл КП, а не на поставщика: у поставщика может быть несколько КП.
|
||||
const seen = new Set<number>();
|
||||
const pairs: Array<{ supplierSpId: number; offerSpId: number }> = [];
|
||||
for (const item of data.value ?? []) {
|
||||
const supplierSpId = Number(item["supplierId"]);
|
||||
const offerSpId = Number(item["offerFileId"] ?? 0);
|
||||
if (!offerSpId || !supplierSpId || seen.has(supplierSpId)) continue;
|
||||
seen.add(supplierSpId);
|
||||
if (!offerSpId || !supplierSpId || seen.has(offerSpId)) continue;
|
||||
seen.add(offerSpId);
|
||||
pairs.push({ supplierSpId, offerSpId });
|
||||
}
|
||||
if (pairs.length === 0) return [];
|
||||
@@ -1541,13 +1542,14 @@ export class CtService {
|
||||
supplierPosition: string;
|
||||
unit: string;
|
||||
itemIndex: number;
|
||||
offerFileSpId?: number;
|
||||
}>
|
||||
> {
|
||||
if (positionSpIds.length === 0) return [];
|
||||
const filter = positionSpIds
|
||||
.map((id) => `positionId eq ${id}`)
|
||||
.join(" or ");
|
||||
const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit&$filter=${filter}`;
|
||||
const url = `${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,price,quantity,supplierPosition,unit,offerFileId&$filter=${filter}`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
@@ -1560,6 +1562,7 @@ export class CtService {
|
||||
supplierPosition: String(item.supplierPosition ?? ""),
|
||||
unit: String(item.unit ?? ""),
|
||||
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
|
||||
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||
}));
|
||||
return keepLatestOfferRecords(records);
|
||||
}
|
||||
@@ -1611,6 +1614,7 @@ export class CtService {
|
||||
id: created.Id,
|
||||
supplierSpId: params.supplierSpId,
|
||||
positionSpId: params.positionSpId,
|
||||
offerFileSpId: params.offerSpId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1618,9 +1622,10 @@ export class CtService {
|
||||
id: number;
|
||||
supplierSpId: number;
|
||||
positionSpId: number;
|
||||
offerFileSpId: number;
|
||||
}): Promise<void> {
|
||||
const url =
|
||||
`${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId` +
|
||||
`${this._li(LISTS.OFFERS)}?$select=Id,Title,positionId,supplierId,offerFileId` +
|
||||
`&$filter=supplierId eq ${saved.supplierSpId} and positionId eq ${saved.positionSpId}`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
// Не критично для обработки КП: оставшиеся дубли отсеивают загрузчики (keepLatestOfferRecords),
|
||||
@@ -1635,6 +1640,7 @@ export class CtService {
|
||||
supplierSpId: Number(item.supplierId),
|
||||
positionSpId: Number(item.positionId),
|
||||
itemIndex: parseOfferItemIndex(String(item.Title ?? "")),
|
||||
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||
}));
|
||||
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||
await this._runChunked(findSupersededOfferIds(existing, saved), 20, async (id) => {
|
||||
|
||||
@@ -117,3 +117,35 @@ describe('findSupplierOfferIds', () => {
|
||||
expect(findSupplierOfferIds(records, 18, [121])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('несколько КП одного поставщика (offerFileSpId)', () => {
|
||||
// Отдельные предложения поставщика (KZT и USD) — разные файлы КП, их записи не дубли друг друга.
|
||||
it('keepLatestOfferRecords не схлопывает записи разных файлов КП', () => {
|
||||
const records = [
|
||||
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
|
||||
];
|
||||
|
||||
expect(keepLatestOfferRecords(records)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keepLatestOfferRecords схлопывает дубли внутри одного файла', () => {
|
||||
const records = [
|
||||
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||
makeRecord({ id: 11, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||
];
|
||||
|
||||
expect(keepLatestOfferRecords(records).map(r => r.id)).toEqual([11]);
|
||||
});
|
||||
|
||||
it('findSupersededOfferIds не трогает записи другого КП того же поставщика', () => {
|
||||
const saved = makeRecord({ id: 20, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 });
|
||||
const existing = [
|
||||
makeRecord({ id: 10, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 277 }),
|
||||
makeRecord({ id: 15, supplierSpId: 263, positionSpId: 1543, offerFileSpId: 290 }),
|
||||
saved,
|
||||
];
|
||||
|
||||
expect(findSupersededOfferIds(existing, saved)).toEqual([15]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Записи списка «Оценка поставщиков»: одна запись = одна строка КП поставщика по позиции.
|
||||
// У позиции может быть несколько строк КП (комплект) — они различаются itemIndex.
|
||||
// У позиции может быть несколько строк КП (комплект) — они различаются itemIndex. У поставщика
|
||||
// может быть несколько КП (отдельные предложения, напр. KZT и USD) — они различаются offerFileSpId.
|
||||
|
||||
export interface IOfferRecordKey {
|
||||
// SP Id записи — чем больше, тем позже создана.
|
||||
@@ -7,6 +8,8 @@ export interface IOfferRecordKey {
|
||||
supplierSpId: number;
|
||||
positionSpId: number;
|
||||
itemIndex: number;
|
||||
// SP Id файла КП. Пусто у старых записей и в формах, где несколько КП у поставщика не поддержаны.
|
||||
offerFileSpId?: number;
|
||||
}
|
||||
|
||||
// Title записи: «поставщик-позиция» (первая строка КП) или «поставщик-позиция-N» (строка комплекта N).
|
||||
@@ -16,13 +19,13 @@ export function parseOfferItemIndex(title: string | undefined): number {
|
||||
return parseInt(parts[2], 10) || 0;
|
||||
}
|
||||
|
||||
// Одна запись на (поставщик, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
|
||||
// Одна запись на (поставщик, файл КП, позиция, itemIndex), побеждает последняя (max Id). Дубли копятся,
|
||||
// когда КП обрабатывают повторно: каждая обработка добавляет записи, а прежние удаляются только
|
||||
// при «Сохранить». Без этого на шаге 3 одна позиция рисуется несколькими одинаковыми под-строками.
|
||||
export function keepLatestOfferRecords<T extends IOfferRecordKey>(records: T[]): T[] {
|
||||
const latestByKey = new Map<string, T>();
|
||||
for (const record of records) {
|
||||
const key = `${record.supplierSpId}::${record.positionSpId}::${record.itemIndex}`;
|
||||
const key = `${record.supplierSpId}::${record.offerFileSpId ?? ''}::${record.positionSpId}::${record.itemIndex}`;
|
||||
const previous = latestByKey.get(key);
|
||||
if (!previous || record.id > previous.id) {
|
||||
latestByKey.set(key, record);
|
||||
@@ -43,16 +46,17 @@ export function findSupplierOfferIds(
|
||||
.map(record => record.id);
|
||||
}
|
||||
|
||||
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика по той же позиции,
|
||||
// включая строки комплекта — новый КП поставщика заменяет его прежнее предложение целиком.
|
||||
// Записи новее сохранённой не трогаем: их могла создать параллельная обработка.
|
||||
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
|
||||
// по той же позиции, включая строки комплекта. Записи другого КП того же поставщика — отдельное
|
||||
// предложение, их не трогаем. Записи новее сохранённой тоже: их могла создать параллельная обработка.
|
||||
export function findSupersededOfferIds(
|
||||
existing: IOfferRecordKey[],
|
||||
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId'>,
|
||||
saved: Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'positionSpId' | 'offerFileSpId'>,
|
||||
): number[] {
|
||||
return existing
|
||||
.filter(record =>
|
||||
record.supplierSpId === saved.supplierSpId &&
|
||||
record.offerFileSpId === saved.offerFileSpId &&
|
||||
record.positionSpId === saved.positionSpId &&
|
||||
record.id < saved.id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user