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

Система не отличает исправленный КП от отдельного предложения поставщика: поставщик
определяется только по названию. Теперь, когда поток назвал поставщика, у которого в СТ уже
есть КП, обработка ждёт выбора в окне. «Заменить» — прежние колонки поставщика уходят из
сравнения, их записи в SP — в корзину (после записи нового КП, чтобы не потерять данные при
сбое). «Добавить отдельным предложением» — отдельная колонка. Закрытие окна — «добавить»:
ничего не удаляется. Несколько КП подряд — вопросы по очереди.

Этап 2 из 4 (п.2).
This commit is contained in:
Тимур Городецкий
2026-10-01 12:22:09 +05:00
parent 3e95f1470f
commit 744b603972
10 changed files with 409 additions and 1 deletions
@@ -0,0 +1,41 @@
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>
);
@@ -206,3 +206,16 @@
align-items: center;
gap: 12px;
}
// Окно «У поставщика уже есть КП» (KpReplaceDialog).
.replaceExistingList {
margin: 4px 0 12px;
padding-left: 20px;
font-weight: 600;
}
.replaceHint {
margin: 0;
font-size: 13px;
color: $muted;
}
@@ -11,6 +11,7 @@ import { useKpProcessing } from './useKpProcessing';
import { persistStep2 } from './step2Persist';
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList';
import { KpReplaceDialog } from './KpReplaceDialog';
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import styles from './Step2CommercialOffers.module.scss';
@@ -135,6 +136,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
disabled={isNotificationLink || isReadOnly}
/>
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
{kp.replaceQuestion && (
<KpReplaceDialog question={kp.replaceQuestion} onAnswer={kp.answerReplaceQuestion} />
)}
{kp.isProcessing && (
<Alert variant="info">Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы.</Alert>
)}
@@ -0,0 +1,102 @@
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]);
});
});
@@ -0,0 +1,77 @@
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,6 +4,8 @@ import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
import { buildKpSuppliersAndOffers } from './step2Persist';
import { buildKpOffersToSave } from './kpOfferRecords';
import { KpReplaceChoice, findOtherKpFilesOfSupplier, removeReplacedColumns } from './kpReplace';
import { KpReplaceQuestion, useKpReplaceQuestion } from './useKpReplaceQuestion';
let localIdCounter = 0;
@@ -15,6 +17,9 @@ export interface UseKpProcessingResult {
handleFileRemove: (localId: string) => void;
handleCurrencyChange: (localId: string, currency: string) => void;
isProcessing: boolean;
// Вопрос «заменить / добавить отдельно» для второго КП поставщика (undefined — окна нет).
replaceQuestion: KpReplaceQuestion | undefined;
answerReplaceQuestion: (choice: KpReplaceChoice) => void;
}
// Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex →
@@ -31,6 +36,7 @@ export function useKpProcessing(
const mountedRef = useRef(true);
const formDataRef = useRef(formData);
const kpFilesRef = useRef<KpFile[]>([]);
const replaceQuestion = useKpReplaceQuestion();
useEffect(() => { formDataRef.current = formData; }, [formData]);
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
@@ -69,6 +75,50 @@ export function useKpProcessing(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 => {
if (!mountedRef.current) return;
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f));
@@ -107,9 +157,11 @@ export function useKpProcessing(
// If supplier wasn't set by Syntex, use supplier name from flow result
let resolvedSupplierSpId = supplierSpId;
let resolvedSupplierName = kpFilesRef.current.find(f => f.localId === localId)?.supplierName ?? '';
if (!resolvedSupplierSpId) {
const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier;
if (supplierName) {
resolvedSupplierName = supplierName;
try {
resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName);
patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId });
@@ -119,6 +171,11 @@ export function useKpProcessing(
}
}
// Второй КП того же поставщика — ждём выбора пользователя до записи в SP.
const replaceChoice: KpReplaceChoice = resolvedSupplierSpId
? await askReplaceChoice(localId, fileName, fileSpId, resolvedSupplierSpId, resolvedSupplierName)
: 'separate';
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
@@ -129,6 +186,10 @@ export function useKpProcessing(
})
));
if (replaceChoice === 'replace' && resolvedSupplierSpId) {
await applyReplace(resolvedSupplierSpId, fileSpId);
}
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
if (supplierBIN && resolvedSupplierSpId) {
@@ -285,5 +346,7 @@ export function useKpProcessing(
handleFileRemove,
handleCurrencyChange,
isProcessing,
replaceQuestion: replaceQuestion.current,
answerReplaceQuestion: replaceQuestion.answer,
};
}
@@ -0,0 +1,53 @@
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,6 +25,7 @@ import {
writeRoute,
} from "../../../shared/approval";
import {
findOtherKpOfferIds,
findSupersededOfferIds,
keepLatestOfferRecords,
parseOfferItemIndex,
@@ -1618,6 +1619,35 @@ export class CtService {
});
}
// «Заменить» прежний КП поставщика новым (Шаг 2): записи поставщика по позициям СТ, кроме записей
// нового файла, уходят в корзину. Вызывается после записи предложений нового КП — при сбое записи
// прежнее предложение останется.
async recycleOtherKpOffers(
supplierSpId: number,
positionSpIds: number[],
keepFileSpId: number,
): Promise<void> {
if (positionSpIds.length === 0) return;
const positionFilter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
const url =
`${this._li(LISTS.OFFERS)}?$select=Id,supplierId,offerFileId` +
`&$filter=supplierId eq ${supplierSpId} and (${positionFilter})&$top=5000`;
const res = await this.http.get(url, SPHttpClient.configurations.v1);
if (!res.ok) throw new Error(`Ошибка чтения прежних предложений: ${res.status}`);
const data = await res.json();
const records = ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
id: Number(item.Id),
supplierSpId: Number(item.supplierId),
offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined,
}));
// MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers.
await this._runChunked(findOtherKpOfferIds(records, supplierSpId, keepFileSpId), 20, async (id) => {
await this.http
.post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
});
}
private async _recycleSupersededOffers(saved: {
id: number;
supplierSpId: number;
@@ -1,4 +1,4 @@
import { IOfferRecordKey, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
import { IOfferRecordKey, findOtherKpOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords';
interface ITestRecord extends IOfferRecordKey {
price: number;
@@ -149,3 +149,16 @@ describe('несколько КП одного поставщика (offerFileSp
expect(findSupersededOfferIds(existing, saved)).toEqual([15]);
});
});
describe('findOtherKpOfferIds («Заменить» прежний КП новым)', () => {
it('возвращает записи других КП поставщика и старые записи без файла', () => {
const records = [
makeRecord({ id: 10, supplierSpId: 263, offerFileSpId: 277 }),
makeRecord({ id: 11, supplierSpId: 263 }),
makeRecord({ id: 12, supplierSpId: 263, offerFileSpId: 285 }),
makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 278 }),
];
expect(findOtherKpOfferIds(records, 263, 285)).toEqual([10, 11]);
});
});
@@ -46,6 +46,18 @@ export function findSupplierOfferIds(
.map(record => record.id);
}
// «Заменить» прежний КП поставщика новым: все записи поставщика, кроме записей нового файла КП,
// включая старые записи без файла.
export function findOtherKpOfferIds(
records: Array<Pick<IOfferRecordKey, 'id' | 'supplierSpId' | 'offerFileSpId'>>,
supplierSpId: number,
keepFileSpId: number,
): number[] {
return records
.filter(record => record.supplierSpId === supplierSpId && record.offerFileSpId !== keepFileSpId)
.map(record => record.id);
}
// Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП
// по той же позиции, включая строки комплекта. Записи другого КП того же поставщика — отдельное
// предложение, их не трогаем. Записи новее сохранённой тоже: их могла создать параллельная обработка.