feat(ct): лишний КП поставщика убирается корзиной вместо окна «заменить / добавить»
Окно на каждый второй КП поставщика при пакетной загрузке (6 файлов — 6 окон) было непонятно.
Теперь вопросов при загрузке нет: каждый КП — своя колонка. В «Загруженных КП» файлы одного
поставщика сгруппированы с пометкой «N КП — сравниваются отдельными колонками», а корзина
(и крестик у обработанного файла) убирает КП из сравнения — колонку, цены и записи этого
файла в SP, с подтверждением. Раньше корзина только прятала строку, колонка оставалась.
Так заменяют исправленный КП: загружают новый, старый убирают.
Заменяет окно из 744b603 (п.2).
This commit is contained in:
-41
@@ -1,41 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Button, Dialog } from '@tnsu/ui-kit';
|
|
||||||
import { KpReplaceChoice } from './kpReplace';
|
|
||||||
import { KpReplaceQuestion } from './useKpReplaceQuestion';
|
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
question: KpReplaceQuestion;
|
|
||||||
onAnswer: (choice: KpReplaceChoice) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Окно выбора для второго КП того же поставщика. Закрытие крестиком/Esc = «Добавить отдельно»:
|
|
||||||
// безопасный вариант, ничего не удаляет.
|
|
||||||
export const KpReplaceDialog: React.FC<Props> = ({ question, onAnswer }) => (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title="У поставщика уже есть КП"
|
|
||||||
subText={`${question.supplierName}: загружен ещё один КП — ${question.fileName}`}
|
|
||||||
onDismiss={() => onAnswer('separate')}
|
|
||||||
width="md"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={() => onAnswer('separate')}>
|
|
||||||
Добавить отдельным предложением
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" onClick={() => onAnswer('replace')}>
|
|
||||||
Заменить
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p>Уже загружено:</p>
|
|
||||||
<ul className={styles.replaceExistingList}>
|
|
||||||
{question.existing.map(f => <li key={f.spId ?? f.name}>{f.name}</li>)}
|
|
||||||
</ul>
|
|
||||||
<p className={styles.replaceHint}>
|
|
||||||
«Заменить» — прежний КП уберётся из сравнения, останется новый (исправленный КП, тот же файл повторно).
|
|
||||||
«Добавить отдельным предложением» — оба КП будут отдельными колонками (напр. другая валюта или вариант).
|
|
||||||
</p>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
+13
-8
@@ -207,15 +207,20 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Окно «У поставщика уже есть КП» (KpReplaceDialog).
|
// «Загруженные КП»: файлы одного поставщика сгруппированы, у нескольких КП — пометка.
|
||||||
.replaceExistingList {
|
.kpGroup {
|
||||||
margin: 4px 0 12px;
|
display: flex;
|
||||||
padding-left: 20px;
|
flex-direction: column;
|
||||||
font-weight: 600;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replaceHint {
|
.kpGroupHeader {
|
||||||
margin: 0;
|
display: flex;
|
||||||
font-size: 13px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpGroupNote {
|
||||||
|
font-size: 12px;
|
||||||
color: $muted;
|
color: $muted;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-7
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Alert, Spinner } from '@tnsu/ui-kit';
|
import { Button, Alert, Spinner, confirmModal } from '@tnsu/ui-kit';
|
||||||
import { CurrencyRate, FormStep, Supplier } from '../types/domain';
|
import { CurrencyRate, FormStep, Supplier } from '../types/domain';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
@@ -11,7 +11,7 @@ import { useKpProcessing } from './useKpProcessing';
|
|||||||
import { persistStep2 } from './step2Persist';
|
import { persistStep2 } from './step2Persist';
|
||||||
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
||||||
import { UploadedKpList } from './UploadedKpList';
|
import { UploadedKpList } from './UploadedKpList';
|
||||||
import { KpReplaceDialog } from './KpReplaceDialog';
|
import { removeKpFileFromComparison } from './kpRemoval';
|
||||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
@@ -92,6 +92,48 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Убрать КП из сравнения (корзина в «Загруженные КП» или крестик у обработанного файла): так
|
||||||
|
// заменяют исправленный КП — загружают новый, старый убирают. Колонка и цены уходят сразу,
|
||||||
|
// записи этого файла в SP — в корзину. Сам файл остаётся в библиотеке.
|
||||||
|
const handleRemoveKpFile = async (fileSpId: number): Promise<void> => {
|
||||||
|
const file = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
|
||||||
|
if (!file) {
|
||||||
|
kp.removeFileBySpId(fileSpId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = await confirmModal({
|
||||||
|
title: 'Убрать КП из сравнения?',
|
||||||
|
message: `${file.name}${file.supplierName ? ` (${file.supplierName})` : ''} — его колонка и цены уйдут из таблицы сравнения. Файл останется в библиотеке КП.`,
|
||||||
|
confirmText: 'Убрать',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
kp.removeFileBySpId(fileSpId);
|
||||||
|
onSave(removeKpFileFromComparison(
|
||||||
|
{ suppliers: formData.suppliers ?? [], offers: formData.offers ?? [], uploadedKpFiles: formData.uploadedKpFiles },
|
||||||
|
file,
|
||||||
|
));
|
||||||
|
const positionSpIds = [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])]
|
||||||
|
.map(p => p.spId)
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
service.recycleKpFileOffers(fileSpId, positionSpIds).catch(err => {
|
||||||
|
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице.
|
||||||
|
console.warn('[КП] не удалось убрать предложения КП из SP:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Крестик в очереди загрузки: обработанный КП уже в сравнении — убираем его так же, как корзиной;
|
||||||
|
// файл в процессе или с ошибкой — просто убираем из очереди.
|
||||||
|
const handleQueueFileRemove = (localId: string): void => {
|
||||||
|
const file = kp.kpFiles.find(f => f.localId === localId);
|
||||||
|
if (file?.status === 'done' && file.spId !== undefined) {
|
||||||
|
handleRemoveKpFile(file.spId).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
kp.handleFileRemove(localId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
@@ -130,15 +172,12 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
<FileUploadArea
|
<FileUploadArea
|
||||||
files={uploadedFiles}
|
files={uploadedFiles}
|
||||||
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
|
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
|
||||||
onFileRemove={kp.handleFileRemove}
|
onFileRemove={handleQueueFileRemove}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
onCurrencyChange={kp.handleCurrencyChange}
|
onCurrencyChange={kp.handleCurrencyChange}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={isNotificationLink || isReadOnly}
|
||||||
/>
|
/>
|
||||||
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
|
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
|
||||||
{kp.replaceQuestion && (
|
|
||||||
<KpReplaceDialog question={kp.replaceQuestion} onAnswer={kp.answerReplaceQuestion} />
|
|
||||||
)}
|
|
||||||
{kp.isProcessing && (
|
{kp.isProcessing && (
|
||||||
<Alert variant="info">Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы.</Alert>
|
<Alert variant="info">Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы.</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +198,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||||
onCurrencyChange={handleUploadedCurrencyChange}
|
onCurrencyChange={handleUploadedCurrencyChange}
|
||||||
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
|
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={isNotificationLink || isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+24
-44
@@ -1,8 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
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 { KpFileRef } from './kpCurrency';
|
||||||
|
import { groupUploadedKpFiles } from './kpRemoval';
|
||||||
|
import { UploadedKpRow } from './UploadedKpRow';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,54 +16,33 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл, валюта КП и удаление.
|
// Секция «Загруженные КП» (постоянный список из formData), сгруппированная по поставщику. Каждый КП —
|
||||||
// Валюту здесь можно поправить и после возврата с Шага 3 — очередь загрузки к этому моменту уже пуста.
|
// отдельная колонка сравнения; несколько КП одного поставщика стоят рядом с пометкой, лишний
|
||||||
export const UploadedKpList: React.FC<Props> = ({
|
// (напр. старая версия КП) убирают корзиной. Валюту можно поправить и после возврата с Шага 3.
|
||||||
files,
|
export const UploadedKpList: React.FC<Props> = ({ files, ...rowProps }) => (
|
||||||
service,
|
|
||||||
currencyOptions,
|
|
||||||
getCurrency,
|
|
||||||
onCurrencyChange,
|
|
||||||
onRemove,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
|
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
|
||||||
<ul className={styles.noOfferList}>
|
<ul className={styles.noOfferList}>
|
||||||
{files.map(f => (
|
{groupUploadedKpFiles(files).map(group => {
|
||||||
<li key={f.spId} className={styles.noOfferItem}>
|
const isMultiple = group.files.length > 1;
|
||||||
{f.supplierName && <span className={styles.noOfferName}>{f.supplierName}</span>}
|
return (
|
||||||
<a
|
<li key={group.files[0].spId} className={styles.kpGroup}>
|
||||||
href={service.getKpFileUrl(f.name)}
|
{isMultiple && (
|
||||||
target="_blank"
|
<div className={styles.kpGroupHeader}>
|
||||||
rel="noopener noreferrer"
|
<span className={styles.noOfferName}>{group.supplierName}</span>
|
||||||
className={styles.kpFileLink}
|
<span className={styles.kpGroupNote}>
|
||||||
>
|
{group.files.length} КП — сравниваются отдельными колонками; лишний уберите корзиной
|
||||||
{f.name}
|
</span>
|
||||||
</a>
|
</div>
|
||||||
{disabled ? (
|
|
||||||
<span className={styles.kpCurrencyCode}>{getCurrency(f)}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
className={styles.kpCurrencySelect}
|
|
||||||
value={getCurrency(f)}
|
|
||||||
onChange={e => onCurrencyChange(f.spId, e.target.value)}
|
|
||||||
// Валюта хранится на поставщике — без него менять нечего.
|
|
||||||
disabled={!f.supplierName}
|
|
||||||
aria-label={`Валюта КП ${f.name}`}
|
|
||||||
>
|
|
||||||
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
title="Удалить КП из списка"
|
|
||||||
onClick={() => onRemove(f.spId)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
<ul className={styles.noOfferList}>
|
||||||
|
{group.files.map(file => (
|
||||||
|
<UploadedKpRow key={file.spId} file={file} showSupplierName={!isMultiple} {...rowProps} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
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 {
|
||||||
|
file: CTFormData['uploadedKpFiles'][number];
|
||||||
|
// Название поставщика в строке — когда у него один КП (в группе из нескольких оно в заголовке группы).
|
||||||
|
showSupplierName: boolean;
|
||||||
|
service: CtService;
|
||||||
|
currencyOptions: string[];
|
||||||
|
getCurrency: (file: KpFileRef) => string;
|
||||||
|
onCurrencyChange: (spId: number, currencyCode: string) => void;
|
||||||
|
onRemove: (spId: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка «Загруженные КП»: ссылка на файл, валюта КП и «убрать из сравнения».
|
||||||
|
export const UploadedKpRow: React.FC<Props> = ({
|
||||||
|
file,
|
||||||
|
showSupplierName,
|
||||||
|
service,
|
||||||
|
currencyOptions,
|
||||||
|
getCurrency,
|
||||||
|
onCurrencyChange,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<li className={styles.noOfferItem}>
|
||||||
|
{showSupplierName && file.supplierName && <span className={styles.noOfferName}>{file.supplierName}</span>}
|
||||||
|
<a
|
||||||
|
href={service.getKpFileUrl(file.name)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.kpFileLink}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
{disabled ? (
|
||||||
|
<span className={styles.kpCurrencyCode}>{getCurrency(file)}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className={styles.kpCurrencySelect}
|
||||||
|
value={getCurrency(file)}
|
||||||
|
onChange={e => onCurrencyChange(file.spId, e.target.value)}
|
||||||
|
// Валюта хранится на колонке поставщика — без него менять нечего.
|
||||||
|
disabled={!file.supplierName}
|
||||||
|
aria-label={`Валюта КП ${file.name}`}
|
||||||
|
>
|
||||||
|
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
title="Убрать КП из сравнения"
|
||||||
|
onClick={() => onRemove(file.spId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { groupUploadedKpFiles, removeKpFileFromComparison } from './kpRemoval';
|
||||||
|
import { Supplier, SupplierOffer } from '../types/domain';
|
||||||
|
|
||||||
|
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||||
|
const KALA = 'ТОО «Кала Саплай»';
|
||||||
|
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true };
|
||||||
|
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true };
|
||||||
|
const KALA_KZT: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: KALA, hasOffer: true };
|
||||||
|
const UPLOADED = [
|
||||||
|
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN },
|
||||||
|
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: KALA },
|
||||||
|
{ spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeOffer(supplierId: string): SupplierOffer {
|
||||||
|
return {
|
||||||
|
id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT',
|
||||||
|
pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeKpFileFromComparison', () => {
|
||||||
|
// Загрузили исправленный КП Арлан — старый убирают корзиной: уходит только его колонка.
|
||||||
|
it('убирает колонку, предложения и строку списка только этого КП', () => {
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{
|
||||||
|
suppliers: [ARLAN_KZT, KALA_KZT, ARLAN_USD],
|
||||||
|
offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')],
|
||||||
|
uploadedKpFiles: UPLOADED,
|
||||||
|
},
|
||||||
|
UPLOADED[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']);
|
||||||
|
expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']);
|
||||||
|
expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старую колонку без файла убирает, когда это последний КП поставщика', () => {
|
||||||
|
const legacy: Supplier = { id: '264', spId: 264, name: KALA, hasOffer: true };
|
||||||
|
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{ suppliers: [legacy], offers: [makeOffer('264')], uploadedKpFiles: [UPLOADED[1]] },
|
||||||
|
UPLOADED[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers).toEqual([]);
|
||||||
|
expect(result.offers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старую колонку без файла оставляет, пока у поставщика есть другие КП', () => {
|
||||||
|
const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true };
|
||||||
|
|
||||||
|
const result = removeKpFileFromComparison(
|
||||||
|
{ suppliers: [legacy], offers: [], uploadedKpFiles: [UPLOADED[0], UPLOADED[2]] },
|
||||||
|
UPLOADED[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suppliers).toEqual([legacy]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupUploadedKpFiles', () => {
|
||||||
|
it('группирует КП по поставщику в порядке появления', () => {
|
||||||
|
const groups = groupUploadedKpFiles(UPLOADED);
|
||||||
|
|
||||||
|
expect(groups.map(g => ({ supplierName: g.supplierName, spIds: g.files.map(f => f.spId) }))).toEqual([
|
||||||
|
{ supplierName: ARLAN, spIds: [277, 285] },
|
||||||
|
{ supplierName: KALA, spIds: [278] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('файл без поставщика — отдельной группой', () => {
|
||||||
|
const groups = groupUploadedKpFiles([{ spId: 1, name: 'a.pdf' }, { spId: 2, name: 'b.pdf' }]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0].supplierName).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
|
||||||
|
type UploadedKpFile = CTFormData['uploadedKpFiles'][number];
|
||||||
|
type ComparisonData = Pick<CTFormData, 'suppliers' | 'offers' | 'uploadedKpFiles'>;
|
||||||
|
|
||||||
|
// Убрать КП из сравнения (корзина в «Загруженные КП»): его колонку, предложения и строку списка.
|
||||||
|
// Так заменяют исправленный КП — загружают новый, старый убирают. Сам файл остаётся в библиотеке.
|
||||||
|
// Старая колонка без файла (kpFileSpId) общая на все КП поставщика — убираем её, только когда
|
||||||
|
// у поставщика не остаётся других КП.
|
||||||
|
export function removeKpFileFromComparison(data: ComparisonData, file: UploadedKpFile): ComparisonData {
|
||||||
|
const remainingFiles = data.uploadedKpFiles.filter(f => f.spId !== file.spId);
|
||||||
|
const hasFileColumn = data.suppliers.some(s => s.kpFileSpId === file.spId);
|
||||||
|
const supplierHasOtherFiles = remainingFiles.some(f => f.supplierName === file.supplierName);
|
||||||
|
|
||||||
|
const isRemoved = (s: CTFormData['suppliers'][number]): boolean => {
|
||||||
|
if (!s.hasOffer) return false;
|
||||||
|
if (hasFileColumn) return s.kpFileSpId === file.spId;
|
||||||
|
return s.kpFileSpId === undefined && s.name === file.supplierName && !supplierHasOtherFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedColumnIds = new Set(data.suppliers.filter(isRemoved).map(s => s.id));
|
||||||
|
return {
|
||||||
|
suppliers: data.suppliers.filter(s => !removedColumnIds.has(s.id)),
|
||||||
|
offers: data.offers.filter(o => !removedColumnIds.has(o.supplierId)),
|
||||||
|
uploadedKpFiles: remainingFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedKpGroup {
|
||||||
|
supplierName?: string;
|
||||||
|
files: UploadedKpFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группы «поставщик → его КП» для списка «Загруженные КП», в порядке первого появления. Несколько КП
|
||||||
|
// одного поставщика стоят рядом — видно, что они сравниваются отдельными колонками.
|
||||||
|
export function groupUploadedKpFiles(files: UploadedKpFile[]): UploadedKpGroup[] {
|
||||||
|
const groups: UploadedKpGroup[] = [];
|
||||||
|
files.forEach(file => {
|
||||||
|
const group = file.supplierName ? groups.find(g => g.supplierName === file.supplierName) : undefined;
|
||||||
|
if (group) {
|
||||||
|
group.files.push(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.push({ supplierName: file.supplierName, files: [file] });
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
-102
@@ -1,102 +0,0 @@
|
|||||||
import { findOtherKpFilesOfSupplier, removeReplacedColumns } from './kpReplace';
|
|
||||||
import { Supplier, SupplierOffer } from '../types/domain';
|
|
||||||
|
|
||||||
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
|
||||||
const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true };
|
|
||||||
const KALA_COLUMN: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true };
|
|
||||||
const UPLOADED = [
|
|
||||||
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN },
|
|
||||||
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: 'ТОО «Кала Саплай»' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function makeOffer(supplierId: string): SupplierOffer {
|
|
||||||
return {
|
|
||||||
id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT',
|
|
||||||
pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('findOtherKpFilesOfSupplier', () => {
|
|
||||||
// Пришёл второй КП Арлан (файл 285) — спросить, заменить ли им KP_01_Arlan_Office_KZT.pdf.
|
|
||||||
it('находит уже загруженный КП того же поставщика', () => {
|
|
||||||
const other = findOtherKpFilesOfSupplier({
|
|
||||||
columns: [KZT_COLUMN, KALA_COLUMN],
|
|
||||||
uploadedKpFiles: UPLOADED,
|
|
||||||
sessionFiles: [],
|
|
||||||
supplierSpId: 263,
|
|
||||||
fileSpId: 285,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(other).toEqual([{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('первый КП поставщика — спрашивать нечего', () => {
|
|
||||||
const other = findOtherKpFilesOfSupplier({
|
|
||||||
columns: [KALA_COLUMN],
|
|
||||||
uploadedKpFiles: UPLOADED,
|
|
||||||
sessionFiles: [],
|
|
||||||
supplierSpId: 263,
|
|
||||||
fileSpId: 285,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(other).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('тот же файл, обработанный повторно, не считается другим КП', () => {
|
|
||||||
const other = findOtherKpFilesOfSupplier({
|
|
||||||
columns: [KZT_COLUMN],
|
|
||||||
uploadedKpFiles: UPLOADED,
|
|
||||||
sessionFiles: [],
|
|
||||||
supplierSpId: 263,
|
|
||||||
fileSpId: 277,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(other).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('видит КП того же поставщика, который ещё обрабатывается в этой сессии', () => {
|
|
||||||
const other = findOtherKpFilesOfSupplier({
|
|
||||||
columns: [],
|
|
||||||
uploadedKpFiles: [],
|
|
||||||
sessionFiles: [{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierSpId: 263 }],
|
|
||||||
supplierSpId: 263,
|
|
||||||
fileSpId: 285,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(other).toEqual([{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('старая колонка без файла КП тоже считается загруженным КП', () => {
|
|
||||||
const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true };
|
|
||||||
|
|
||||||
const other = findOtherKpFilesOfSupplier({
|
|
||||||
columns: [legacy],
|
|
||||||
uploadedKpFiles: [],
|
|
||||||
sessionFiles: [],
|
|
||||||
supplierSpId: 263,
|
|
||||||
fileSpId: 285,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(other).toEqual([{ spId: undefined, name: ARLAN }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removeReplacedColumns', () => {
|
|
||||||
it('«Заменить» убирает прежние колонки поставщика, их предложения и файлы', () => {
|
|
||||||
const usdColumn: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true };
|
|
||||||
|
|
||||||
const result = removeReplacedColumns(
|
|
||||||
{
|
|
||||||
suppliers: [KZT_COLUMN, KALA_COLUMN, usdColumn],
|
|
||||||
offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')],
|
|
||||||
uploadedKpFiles: [...UPLOADED, { spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN }],
|
|
||||||
},
|
|
||||||
263,
|
|
||||||
285,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']);
|
|
||||||
expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']);
|
|
||||||
expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Supplier, SupplierOffer } from '../types/domain';
|
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
|
||||||
|
|
||||||
// Второй КП того же поставщика: пользователь выбирает, заменить им прежний (исправленный КП, тот же
|
|
||||||
// файл под другим именем) или добавить отдельным предложением (напр. другая валюта). Сама система
|
|
||||||
// эти случаи не различает — поставщик определяется только по названию.
|
|
||||||
export type KpReplaceChoice = 'replace' | 'separate';
|
|
||||||
|
|
||||||
// Уже загруженный КП поставщика — для текста вопроса. spId нет у старых колонок без файла КП.
|
|
||||||
export interface ExistingKpFile {
|
|
||||||
spId?: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FindOtherKpFilesArgs {
|
|
||||||
columns: Supplier[];
|
|
||||||
uploadedKpFiles: CTFormData['uploadedKpFiles'];
|
|
||||||
// КП этой сессии, которые ещё обрабатываются: их колонки появятся только после обработки.
|
|
||||||
sessionFiles: Array<{ spId?: number; name: string; supplierSpId?: number }>;
|
|
||||||
supplierSpId: number;
|
|
||||||
fileSpId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Другие КП того же поставщика в этой СТ (кроме только что обработанного файла).
|
|
||||||
export function findOtherKpFilesOfSupplier(args: FindOtherKpFilesArgs): ExistingKpFile[] {
|
|
||||||
const { columns, uploadedKpFiles, sessionFiles, supplierSpId, fileSpId } = args;
|
|
||||||
const found: ExistingKpFile[] = [];
|
|
||||||
const seenFileIds = new Set<number>([fileSpId]);
|
|
||||||
|
|
||||||
columns
|
|
||||||
.filter(c => c.hasOffer && c.spId === supplierSpId)
|
|
||||||
.forEach(c => {
|
|
||||||
if (c.kpFileSpId === undefined) {
|
|
||||||
// Старая колонка без файла КП (данные до появления нескольких КП у поставщика).
|
|
||||||
found.push({ spId: undefined, name: c.name });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (seenFileIds.has(c.kpFileSpId)) return;
|
|
||||||
seenFileIds.add(c.kpFileSpId);
|
|
||||||
const uploaded = uploadedKpFiles.find(u => u.spId === c.kpFileSpId);
|
|
||||||
found.push({ spId: c.kpFileSpId, name: uploaded?.name ?? c.name });
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionFiles
|
|
||||||
.filter(f => f.supplierSpId === supplierSpId && f.spId !== undefined && !seenFileIds.has(f.spId))
|
|
||||||
.forEach(f => {
|
|
||||||
seenFileIds.add(f.spId as number);
|
|
||||||
found.push({ spId: f.spId, name: f.name });
|
|
||||||
});
|
|
||||||
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReplaceableData = Pick<CTFormData, 'suppliers' | 'offers' | 'uploadedKpFiles'>;
|
|
||||||
|
|
||||||
// «Заменить»: убираем прежние колонки поставщика (все, кроме колонки нового файла КП), их предложения
|
|
||||||
// и записи в списке «Загруженные КП». Сам файл остаётся в библиотеке — удаляются только данные СТ.
|
|
||||||
export function removeReplacedColumns(
|
|
||||||
data: ReplaceableData,
|
|
||||||
supplierSpId: number,
|
|
||||||
keepFileSpId: number,
|
|
||||||
): ReplaceableData {
|
|
||||||
const isReplaced = (s: Supplier): boolean =>
|
|
||||||
s.hasOffer === true && s.spId === supplierSpId && s.kpFileSpId !== keepFileSpId;
|
|
||||||
|
|
||||||
const replacedColumns = data.suppliers.filter(isReplaced);
|
|
||||||
const replacedColumnIds = new Set(replacedColumns.map(s => s.id));
|
|
||||||
const replacedFileIds = new Set(
|
|
||||||
replacedColumns.map(s => s.kpFileSpId).filter((id): id is number => id !== undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
suppliers: data.suppliers.filter(s => !isReplaced(s)),
|
|
||||||
offers: data.offers.filter((o: SupplierOffer) => !replacedColumnIds.has(o.supplierId)),
|
|
||||||
uploadedKpFiles: data.uploadedKpFiles.filter(f => !replacedFileIds.has(f.spId)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+7
-63
@@ -4,8 +4,6 @@ import { CTFormData } from '../hooks/useCtForm';
|
|||||||
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
||||||
import { buildKpSuppliersAndOffers } from './step2Persist';
|
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||||
import { buildKpOffersToSave } from './kpOfferRecords';
|
import { buildKpOffersToSave } from './kpOfferRecords';
|
||||||
import { KpReplaceChoice, findOtherKpFilesOfSupplier, removeReplacedColumns } from './kpReplace';
|
|
||||||
import { KpReplaceQuestion, useKpReplaceQuestion } from './useKpReplaceQuestion';
|
|
||||||
|
|
||||||
let localIdCounter = 0;
|
let localIdCounter = 0;
|
||||||
|
|
||||||
@@ -16,10 +14,9 @@ export interface UseKpProcessingResult {
|
|||||||
handleFilesAdded: (files: File[]) => Promise<void>;
|
handleFilesAdded: (files: File[]) => Promise<void>;
|
||||||
handleFileRemove: (localId: string) => void;
|
handleFileRemove: (localId: string) => void;
|
||||||
handleCurrencyChange: (localId: string, currency: string) => void;
|
handleCurrencyChange: (localId: string, currency: string) => void;
|
||||||
|
// Убрать файл из очереди загрузки по SP Id (КП убрали из сравнения): иначе он снова соберётся в колонку.
|
||||||
|
removeFileBySpId: (fileSpId: number) => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
// Вопрос «заменить / добавить отдельно» для второго КП поставщика (undefined — окна нет).
|
|
||||||
replaceQuestion: KpReplaceQuestion | undefined;
|
|
||||||
answerReplaceQuestion: (choice: KpReplaceChoice) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex →
|
// Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex →
|
||||||
@@ -36,7 +33,6 @@ export function useKpProcessing(
|
|||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const formDataRef = useRef(formData);
|
const formDataRef = useRef(formData);
|
||||||
const kpFilesRef = useRef<KpFile[]>([]);
|
const kpFilesRef = useRef<KpFile[]>([]);
|
||||||
const replaceQuestion = useKpReplaceQuestion();
|
|
||||||
|
|
||||||
useEffect(() => { formDataRef.current = formData; }, [formData]);
|
useEffect(() => { formDataRef.current = formData; }, [formData]);
|
||||||
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
|
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
|
||||||
@@ -75,50 +71,6 @@ export function useKpProcessing(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [kpFiles]);
|
}, [kpFiles]);
|
||||||
|
|
||||||
// Другие КП этого поставщика в СТ — если есть, спрашиваем «заменить или добавить отдельно».
|
|
||||||
const askReplaceChoice = async (
|
|
||||||
localId: string,
|
|
||||||
fileName: string,
|
|
||||||
fileSpId: number,
|
|
||||||
supplierSpId: number,
|
|
||||||
supplierName: string,
|
|
||||||
): Promise<KpReplaceChoice> => {
|
|
||||||
const fd = formDataRef.current;
|
|
||||||
const existing = findOtherKpFilesOfSupplier({
|
|
||||||
columns: fd.suppliers ?? [],
|
|
||||||
uploadedKpFiles: fd.uploadedKpFiles ?? [],
|
|
||||||
sessionFiles: kpFilesRef.current
|
|
||||||
.filter(f => f.localId !== localId && f.status !== 'error')
|
|
||||||
.map(f => ({ spId: f.spId, name: f.file.name, supplierSpId: f.supplierSpId })),
|
|
||||||
supplierSpId,
|
|
||||||
fileSpId,
|
|
||||||
});
|
|
||||||
if (existing.length === 0) return 'separate';
|
|
||||||
return replaceQuestion.ask({ fileName, supplierName, existing });
|
|
||||||
};
|
|
||||||
|
|
||||||
// «Заменить»: прежние КП поставщика уходят из сравнения — из стейта сразу, из SP (в корзину) после
|
|
||||||
// записи предложений нового КП. Стейт обновляем до статуса done нового файла: иначе инкрементальный
|
|
||||||
// persist ниже успеет собрать колонки по ещё не очищенным formData.
|
|
||||||
const applyReplace = async (supplierSpId: number, keepFileSpId: number): Promise<void> => {
|
|
||||||
const fd = formDataRef.current;
|
|
||||||
setKpFiles(prev => prev.filter(f => f.supplierSpId !== supplierSpId || f.spId === keepFileSpId));
|
|
||||||
onSave(removeReplacedColumns(
|
|
||||||
{ suppliers: fd.suppliers ?? [], offers: fd.offers ?? [], uploadedKpFiles: fd.uploadedKpFiles ?? [] },
|
|
||||||
supplierSpId,
|
|
||||||
keepFileSpId,
|
|
||||||
));
|
|
||||||
const positionSpIds = [...fd.selectedPositions, ...(fd.step3ExtraPositions ?? [])]
|
|
||||||
.map(p => p.spId)
|
|
||||||
.filter((id): id is number => id !== undefined);
|
|
||||||
try {
|
|
||||||
await service.recycleOtherKpOffers(supplierSpId, positionSpIds, keepFileSpId);
|
|
||||||
} catch (err) {
|
|
||||||
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице.
|
|
||||||
console.warn('[КП] не удалось убрать предложения прежнего КП:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchFile = (localId: string, patch: Partial<KpFile>): void => {
|
const patchFile = (localId: string, patch: Partial<KpFile>): void => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f));
|
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f));
|
||||||
@@ -157,11 +109,9 @@ export function useKpProcessing(
|
|||||||
|
|
||||||
// If supplier wasn't set by Syntex, use supplier name from flow result
|
// If supplier wasn't set by Syntex, use supplier name from flow result
|
||||||
let resolvedSupplierSpId = supplierSpId;
|
let resolvedSupplierSpId = supplierSpId;
|
||||||
let resolvedSupplierName = kpFilesRef.current.find(f => f.localId === localId)?.supplierName ?? '';
|
|
||||||
if (!resolvedSupplierSpId) {
|
if (!resolvedSupplierSpId) {
|
||||||
const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier;
|
const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier;
|
||||||
if (supplierName) {
|
if (supplierName) {
|
||||||
resolvedSupplierName = supplierName;
|
|
||||||
try {
|
try {
|
||||||
resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName);
|
resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName);
|
||||||
patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId });
|
patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId });
|
||||||
@@ -171,11 +121,6 @@ export function useKpProcessing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Второй КП того же поставщика — ждём выбора пользователя до записи в SP.
|
|
||||||
const replaceChoice: KpReplaceChoice = resolvedSupplierSpId
|
|
||||||
? await askReplaceChoice(localId, fileName, fileSpId, resolvedSupplierSpId, resolvedSupplierName)
|
|
||||||
: 'separate';
|
|
||||||
|
|
||||||
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
|
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
|
||||||
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
|
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
|
||||||
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
|
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
|
||||||
@@ -186,10 +131,6 @@ export function useKpProcessing(
|
|||||||
})
|
})
|
||||||
));
|
));
|
||||||
|
|
||||||
if (replaceChoice === 'replace' && resolvedSupplierSpId) {
|
|
||||||
await applyReplace(resolvedSupplierSpId, fileSpId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
|
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
|
||||||
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
|
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
|
||||||
if (supplierBIN && resolvedSupplierSpId) {
|
if (supplierBIN && resolvedSupplierSpId) {
|
||||||
@@ -332,6 +273,10 @@ export function useKpProcessing(
|
|||||||
setKpFiles(prev => prev.filter(f => f.localId !== localId));
|
setKpFiles(prev => prev.filter(f => f.localId !== localId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeFileBySpId = (fileSpId: number): void => {
|
||||||
|
setKpFiles(prev => prev.filter(f => f.spId !== fileSpId));
|
||||||
|
};
|
||||||
|
|
||||||
const handleCurrencyChange = (localId: string, currency: string): void => {
|
const handleCurrencyChange = (localId: string, currency: string): void => {
|
||||||
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
|
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
|
||||||
};
|
};
|
||||||
@@ -345,8 +290,7 @@ export function useKpProcessing(
|
|||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
handleFileRemove,
|
handleFileRemove,
|
||||||
handleCurrencyChange,
|
handleCurrencyChange,
|
||||||
|
removeFileBySpId,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
replaceQuestion: replaceQuestion.current,
|
|
||||||
answerReplaceQuestion: replaceQuestion.answer,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { ExistingKpFile, KpReplaceChoice } from './kpReplace';
|
|
||||||
|
|
||||||
// Вопрос «заменить или добавить отдельно» по одному обработанному КП.
|
|
||||||
export interface KpReplaceQuestion {
|
|
||||||
fileName: string;
|
|
||||||
supplierName: string;
|
|
||||||
existing: ExistingKpFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingQuestion {
|
|
||||||
question: KpReplaceQuestion;
|
|
||||||
resolve: (choice: KpReplaceChoice) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseKpReplaceQuestionResult {
|
|
||||||
// Вопрос, который сейчас показан (undefined — окна нет).
|
|
||||||
current: KpReplaceQuestion | undefined;
|
|
||||||
ask: (question: KpReplaceQuestion) => Promise<KpReplaceChoice>;
|
|
||||||
answer: (choice: KpReplaceChoice) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очередь вопросов: несколько КП обрабатываются параллельно, а окно одно — спрашиваем по очереди.
|
|
||||||
// Обработка КП ждёт ответа (Promise) перед записью предложений в SP.
|
|
||||||
// Ушли с шага, не ответив — считаем «добавить отдельно»: так ничего не удаляется.
|
|
||||||
export function useKpReplaceQuestion(): UseKpReplaceQuestionResult {
|
|
||||||
const queueRef = useRef<PendingQuestion[]>([]);
|
|
||||||
const [current, setCurrent] = useState<KpReplaceQuestion | undefined>(undefined);
|
|
||||||
|
|
||||||
const showNext = useCallback((): void => {
|
|
||||||
setCurrent(queueRef.current[0]?.question);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ask = useCallback((question: KpReplaceQuestion): Promise<KpReplaceChoice> => {
|
|
||||||
return new Promise<KpReplaceChoice>(resolve => {
|
|
||||||
queueRef.current.push({ question, resolve });
|
|
||||||
if (queueRef.current.length === 1) showNext();
|
|
||||||
});
|
|
||||||
}, [showNext]);
|
|
||||||
|
|
||||||
const answer = useCallback((choice: KpReplaceChoice): void => {
|
|
||||||
const pending = queueRef.current.shift();
|
|
||||||
pending?.resolve(choice);
|
|
||||||
showNext();
|
|
||||||
}, [showNext]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
queueRef.current.forEach(p => p.resolve('separate'));
|
|
||||||
queueRef.current = [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { current, ask, answer };
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
writeRoute,
|
writeRoute,
|
||||||
} from "../../../shared/approval";
|
} from "../../../shared/approval";
|
||||||
import {
|
import {
|
||||||
findOtherKpOfferIds,
|
findKpFileOfferIds,
|
||||||
findSupersededOfferIds,
|
findSupersededOfferIds,
|
||||||
keepLatestOfferRecords,
|
keepLatestOfferRecords,
|
||||||
parseOfferItemIndex,
|
parseOfferItemIndex,
|
||||||
@@ -1619,29 +1619,24 @@ export class CtService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Заменить» прежний КП поставщика новым (Шаг 2): записи поставщика по позициям СТ, кроме записей
|
// КП убрали из сравнения (Шаг 2, корзина в «Загруженные КП»): записи этого файла по позициям СТ
|
||||||
// нового файла, уходят в корзину. Вызывается после записи предложений нового КП — при сбое записи
|
// уходят в корзину. Сам файл остаётся в библиотеке. Старые записи без offerFileId не найти —
|
||||||
// прежнее предложение останется.
|
// их перезапишет «Сохранить» по текущей таблице.
|
||||||
async recycleOtherKpOffers(
|
async recycleKpFileOffers(fileSpId: number, positionSpIds: number[]): Promise<void> {
|
||||||
supplierSpId: number,
|
|
||||||
positionSpIds: number[],
|
|
||||||
keepFileSpId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
if (positionSpIds.length === 0) return;
|
if (positionSpIds.length === 0) return;
|
||||||
const positionFilter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
const positionFilter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
||||||
const url =
|
const url =
|
||||||
`${this._li(LISTS.OFFERS)}?$select=Id,supplierId,offerFileId` +
|
`${this._li(LISTS.OFFERS)}?$select=Id,offerFileId` +
|
||||||
`&$filter=supplierId eq ${supplierSpId} and (${positionFilter})&$top=5000`;
|
`&$filter=offerFileId eq ${fileSpId} and (${positionFilter})&$top=5000`;
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) throw new Error(`Ошибка чтения прежних предложений: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка чтения предложений КП: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
|
||||||
id: Number(item.Id),
|
id: Number(item.Id),
|
||||||
supplierSpId: Number(item.supplierId),
|
|
||||||
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
|
||||||
}));
|
}));
|
||||||
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
|
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||||
await this._runChunked(findOtherKpOfferIds(records, supplierSpId, keepFileSpId), 20, async (id) => {
|
await this._runChunked(findKpFileOfferIds(records, fileSpId), 20, async (id) => {
|
||||||
await this.http
|
await this.http
|
||||||
.post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
.post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IOfferRecordKey, findOtherKpOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
|
import { IOfferRecordKey, findKpFileOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
|
||||||
|
|
||||||
interface ITestRecord extends IOfferRecordKey {
|
interface ITestRecord extends IOfferRecordKey {
|
||||||
price: number;
|
price: number;
|
||||||
@@ -150,15 +150,15 @@ describe('несколько КП одного поставщика (offerFileSp
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findOtherKpOfferIds («Заменить» прежний КП новым)', () => {
|
describe('findKpFileOfferIds (КП убрали из сравнения)', () => {
|
||||||
it('возвращает записи других КП поставщика и старые записи без файла', () => {
|
it('возвращает записи только этого файла КП', () => {
|
||||||
const records = [
|
const records = [
|
||||||
makeRecord({ id: 10, supplierSpId: 263, offerFileSpId: 277 }),
|
makeRecord({ id: 10, supplierSpId: 263, offerFileSpId: 277 }),
|
||||||
makeRecord({ id: 11, supplierSpId: 263 }),
|
makeRecord({ id: 11, supplierSpId: 263 }),
|
||||||
makeRecord({ id: 12, supplierSpId: 263, offerFileSpId: 285 }),
|
makeRecord({ id: 12, supplierSpId: 263, offerFileSpId: 285 }),
|
||||||
makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 278 }),
|
makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 277 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(findOtherKpOfferIds(records, 263, 285)).toEqual([10, 11]);
|
expect(findKpFileOfferIds(records, 277)).toEqual([10, 13]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,16 +46,12 @@ export function findSupplierOfferIds(
|
|||||||
.map(record => record.id);
|
.map(record => record.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Заменить» прежний КП поставщика новым: все записи поставщика, кроме записей нового файла КП,
|
// Записи одного файла КП — когда КП убирают из сравнения (корзина в «Загруженные КП»).
|
||||||
// включая старые записи без файла.
|
export function findKpFileOfferIds(
|
||||||
export function findOtherKpOfferIds(
|
records: Array<Pick<IOfferRecordKey, 'id' | 'offerFileSpId'>>,
|
||||||
records: Array<Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'offerFileSpId'>>,
|
fileSpId: number,
|
||||||
supplierSpId: number,
|
|
||||||
keepFileSpId: number,
|
|
||||||
): number[] {
|
): number[] {
|
||||||
return records
|
return records.filter(record => record.offerFileSpId === fileSpId).map(record => record.id);
|
||||||
.filter(record => record.supplierSpId === supplierSpId && record.offerFileSpId !== keepFileSpId)
|
|
||||||
.map(record => record.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
|
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
|
||||||
|
|||||||
Reference in New Issue
Block a user