feat(ct): лишний КП поставщика убирается корзиной вместо окна «заменить / добавить»

Окно на каждый второй КП поставщика при пакетной загрузке (6 файлов — 6 окон) было непонятно.
Теперь вопросов при загрузке нет: каждый КП — своя колонка. В «Загруженных КП» файлы одного
поставщика сгруппированы с пометкой «N КП — сравниваются отдельными колонками», а корзина
(и крестик у обработанного файла) убирает КП из сравнения — колонку, цены и записи этого
файла в SP, с подтверждением. Раньше корзина только прятала строку, колонка оставалась.
Так заменяют исправленный КП: загружают новый, старый убирают.

Заменяет окно из 744b603 (п.2).
This commit is contained in:
Тимур Городецкий
2026-10-01 12:58:25 +05:00
parent dc88dfe5ff
commit 85145e0d6e
14 changed files with 300 additions and 425 deletions
@@ -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>
);
@@ -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;
} }
@@ -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}
/> />
)} )}
@@ -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> <ul className={styles.noOfferList}>
) : ( {group.files.map(file => (
<> <UploadedKpRow key={file.spId} file={file} showSupplierName={!isMultiple} {...rowProps} />
<select ))}
className={styles.kpCurrencySelect} </ul>
value={getCurrency(f)} </li>
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> </ul>
</section> </section>
); );
@@ -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>
);
@@ -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;
}
@@ -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)),
};
}
@@ -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,
}; };
} }
@@ -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);
} }
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП // Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП