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