feat(ct): поставщика исключают из сравнения с причиной и могут вернуть (#1)

Раньше крестик на Шаге 3 удалял поставщика вместе с ценами, а «Сохранить»
стирал его офферы в SP — случайно удалённого было не вернуть.

Теперь крестик спрашивает обязательную причину и скрывает колонку: таблица,
итоги, рекомендация, ИИ и Adata считаются без него, выбор с исключённого
снимается. Цены остаются в правках и сессии, «Сохранить» пишет их в
«Исключённые предложения», при открытии СТ они подтягиваются. Под таблицей —
список исключённых с кнопкой «Вернуть»; в режиме просмотра скрыт константой.
Повторная загрузка КП на Шаге 2 исключение не снимает.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-27 11:51:45 +05:00
parent 373e34b2cf
commit 74f6d42e4f
12 changed files with 317 additions and 71 deletions
@@ -11,6 +11,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl
import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers';
import { excludedItemsToFormData } from './utils/excludedColumns';
import { isUserInApprovalChain } from './utils/approvalAccess';
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
@@ -82,6 +83,12 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
const preferSp = loadedAccess.isReview;
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Исключённые из сравнения (docs/adr/0001). Не грузятся — СТ открываем без них, а не падаем:
// таблица сравнения важнее, но молча не глотаем.
const excludedItems = await service.loadExcludedOffers(initialCtId).catch(err => {
console.warn('[CtCreateForm] не удалось загрузить исключённых поставщиков:', err);
return [];
});
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
const noOfferFromSp = noOfferRecordsToSuppliers(
await service.loadNoOfferSuppliers(initialCtId).catch(err => {
@@ -103,7 +110,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Load currency rates for the valutaIds referenced by offers (separate query avoids
// SP REST 400 that occurs when selecting a Choice field via $expand on a lookup).
const valutaIds = rawOffers
const valutaIds = [...rawOffers, ...excludedItems]
.map(o => o.valutaSpId)
.filter((id): id is number => id !== undefined && id > 0)
.filter((id, idx, arr) => arr.indexOf(id) === idx);
@@ -154,6 +161,15 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
currencyCodeOf,
fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId),
});
// Исключённые — отдельными колонками с exclusion: Шаг 3 их не сравнивает, но может вернуть.
// Колонка есть и среди обычных — её вернули, а уборка прежних исключённых строк не прошла:
// обычная важнее, иначе колонка задвоится.
const allExcluded = excludedItemsToFormData(excludedItems, currencyCodeOf);
const activeColumnIds = new Set(spSuppliers.map(s => s.id));
const excluded = {
suppliers: allExcluded.suppliers.filter(s => !activeColumnIds.has(s.id)),
offers: allExcluded.offers.filter(o => !activeColumnIds.has(o.supplierId)),
};
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles;
@@ -176,8 +192,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or in review mode (always fresh).
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
...(preferSp || formData.offers.length === 0 ? { offers } : {}),
suppliers: withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
),
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
@@ -196,7 +215,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4).
ensureHighestStep(reachableStep({
hasPositions: positions.length > 0,
hasKp: rawOffers.length > 0 || uploadedKpFiles.length > 0,
hasKp: rawOffers.length > 0 || excludedItems.length > 0 || uploadedKpFiles.length > 0,
hasRoute: approvalMatrix.length > 0,
}));
if (ct.status === 'approved' || ct.status === 'rejected') {
@@ -4,6 +4,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes';
import { buildSupplierColumnId } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { keepExclusions } from '../utils/excludedColumns';
export interface PersistStep2Args {
service: CtService;
@@ -77,7 +78,8 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
savedRates = await service.saveCurrencyRates(currencyRates);
}
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
const { suppliers: builtSuppliers, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
const newSuppliersWithOffer = keepExclusions(builtSuppliers, formData.suppliers ?? []);
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId,
@@ -94,7 +96,11 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
const offersForSP = newOffers
.map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId;
const column = newSuppliersWithOffer.find(s => s.id === o.supplierId);
const supplierSpId = column?.spId;
// Исключённый поставщик не попадает в «Оценку поставщиков» — его строки пишет Шаг 3
// в «Исключённые предложения».
if (column?.exclusion) return null;
if (pos?.spId === undefined || supplierSpId === undefined) return null;
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
})
@@ -5,6 +5,7 @@ import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from '
import { buildKpSuppliersAndOffers } from './step2Persist';
import { buildKpOffersToSave } from './kpOfferRecords';
import { buildKpStorageName } from '../utils/kpFileName';
import { keepExclusions } from '../utils/excludedColumns';
let localIdCounter = 0;
@@ -62,7 +63,7 @@ export function useKpProcessing(
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
// поставщика — отдельное предложение, своя колонка.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
patch.suppliers = [...others, ...kpSuppliers];
patch.suppliers = [...others, ...keepExclusions(kpSuppliers, fd.suppliers ?? [])];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
patch.offers = [...otherOffers, ...kpOffers];
@@ -7,6 +7,7 @@ import { AddExtraRow } from './AddExtraRow';
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
import { UseAdataCheckResult } from './useAdataCheck';
import { AdataIndicator } from './AdataIndicator';
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
@@ -26,9 +27,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
setSelectedSupplierId, handleExcludeSupplier, handleStatusChange, handleNameColResizeMouseDown,
} = matrix;
// Колонка, для которой открыто окно «Исключить из сравнения».
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
@@ -115,8 +120,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</button>
<button
className={styles.deleteSupplierBtn}
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
title="Удалить потенциального поставщика"
onClick={() => setExcludingSupplierId(s.id)}
title="Исключить из сравнения"
>×</button>
</>
) : (
@@ -257,6 +262,17 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
<AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} />
</tbody>
</table>
{excludingSupplier && (
<ExcludeSupplierDialog
supplierName={matrix.columnDisplayNames[excludingSupplier.id] ?? excludingSupplier.name}
onConfirm={reason => {
handleExcludeSupplier(excludingSupplier.id, reason);
setExcludingSupplierId(undefined);
}}
onCancel={() => setExcludingSupplierId(undefined)}
/>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, TextArea } from '@tnsu/ui-kit';
interface Props {
supplierName: string;
onConfirm: (reason: string) => void;
onCancel: () => void;
}
const REASON_ROWS = 3;
// Исключение поставщика из сравнения (docs/adr/0001): причина обязательна — по ней потом видно,
// почему поставщик не рассматривался.
export const ExcludeSupplierDialog: React.FC<Props> = ({ supplierName, onConfirm, onCancel }) => {
const [reason, setReason] = useState('');
const isReasonEmpty = reason.trim().length === 0;
return (
<Dialog
open
title="Исключить из сравнения"
subText={`«${supplierName}» пропадёт из таблицы и итогов. Цены сохранятся — поставщика можно будет вернуть.`}
onDismiss={onCancel}
footer={
<>
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
<Button variant="primary" onClick={() => onConfirm(reason)} disabled={isReasonEmpty}>
Исключить
</Button>
</>
}
>
<TextArea
value={reason}
onChange={setReason}
rows={REASON_ROWS}
placeholder="Причина исключения, например: не прошёл проверку Adata"
ariaLabel="Причина исключения поставщика"
/>
</Dialog>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
export interface ExcludedSupplierEntry {
id: string;
name: string;
reason: string;
}
interface Props {
entries: ExcludedSupplierEntry[];
// Без колбэка — только чтение (кнопки «Вернуть» нет).
onRestore?: (id: string) => void;
}
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001), — под таблицей, рядом со
// списком «не предоставившие КП».
export const ExcludedSuppliersList: React.FC<Props> = ({ entries, onRestore }) => {
if (entries.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Исключены из сравнения</h4>
<ul className={styles.noOfferSupplierList}>
{entries.map(entry => (
<li key={entry.id} className={`${styles.noOfferSupplierItem} ${styles.excludedSupplierItem}`}>
<span className={styles.noOfferSupplierName}>{entry.name}</span>
<span className={styles.noOfferSupplierComment}>{entry.reason}</span>
{onRestore && (
<Button variant="secondary" onClick={() => onRestore(entry.id)}>Вернуть</Button>
)}
</li>
))}
</ul>
</div>
);
};
@@ -675,6 +675,11 @@ $delivery-km-min-width: 76px;
color: $muted;
}
// Кнопка «Вернуть» — по центру строки с именем и причиной.
.excludedSupplierItem {
align-items: center;
}
.criteriaTable {
border-collapse: collapse;
width: 100%;
@@ -18,6 +18,11 @@ import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
const SHOW_EXCLUDED_SUPPLIERS_READ_ONLY = false;
interface Props {
service: CtService;
@@ -53,7 +58,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier,
} = matrix;
const showExcludedSuppliers = !isReadOnly || SHOW_EXCLUDED_SUPPLIERS_READ_ONLY;
// Проверка контрагентов в Adata вынесена в хук.
const adata = useAdataCheck(service, suppliersWithOffer);
@@ -181,6 +188,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
{showExcludedSuppliers && (
<ExcludedSuppliersList
entries={excludedColumns.map(c => ({
id: c.id,
name: columnDisplayNames[c.id] ?? c.name,
reason: c.exclusion.reason,
}))}
onRestore={isReadOnly ? undefined : handleRestoreSupplier}
/>
)}
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />}
@@ -1,5 +1,5 @@
import { SupplierOffer } from '../types/domain';
import { CellEdit } from './matrixTypes';
import { CurrencyRate, SupplierOffer } from '../types/domain';
import { CellEdit, EditableOffers, PositionRow } from './matrixTypes';
// Статус потенциального поставщика — фиксированный список для <select> в шапке (не обязателен).
export const SUPPLIER_STATUS_OPTIONS = [
@@ -87,6 +87,45 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`;
}
export interface ColumnOffersInput {
columns: Array<{ id: string; isExtraSupplier: boolean }>;
positions: PositionRow[];
edits: EditableOffers;
supplierCurrencies: Record<string, string>;
currencyRates: CurrencyRate[];
}
// Офферы колонок по всем позициям из правок таблицы (пустая ячейка — оффер с нулями). Одна функция
// и для сравниваемых поставщиков, и для исключённых: цены исключённых считаются так же, чтобы
// сохраниться в SP и вернуться без потерь.
export function computeColumnsOffers(input: ColumnOffersInput): SupplierOffer[] {
const { columns, positions, edits, supplierCurrencies, currencyRates } = input;
const result: SupplierOffer[] = [];
columns.forEach(s => {
positions.forEach(pos => {
const items = edits[buildOfferKey(s.id, pos.id)] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
// For extra suppliers quantity may be undefined in edits (user only entered price).
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
const posQtyFallback = s.isExtraSupplier ? (pos.quantity || undefined) : undefined;
items.forEach(item => result.push(computeOffer(
{
...item,
unit: item.unit || pos.unit,
supplierProductName: item.supplierProductName || pos.materialName,
quantity: item.quantity ?? posQtyFallback,
},
currency, rate
)));
});
});
return result;
}
// Проставляет id, полученные при создании в SP (доп. строки / поставщики Шага 3), в элементы стейта.
// Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить»
// увидит spId и не создаст ту же строку ещё раз.
@@ -1,10 +1,11 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useRef } from 'react';
import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeOffer, sumPositionTotals } from './matrixHelpers';
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -289,7 +290,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const suppliersWithOffer = useMemo(() => [
// Все колонки поставщиков с КП, включая исключённых из сравнения (docs/adr/0001).
const allColumns = useMemo(() => [
// Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать
// у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras).
...suppliers
@@ -299,19 +301,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const,
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
spId: es.spId, bin: es.bin,
spId: es.spId, bin: es.bin, exclusion: es.exclusion,
})),
], [suppliers, extraSuppliers]);
// Сравниваются только неисключённые: от этого списка считаются таблица, итоги, минимумы,
// рекомендация, ИИ и Adata. Исключённые живут отдельно — их цены остаются в edits.
const suppliersWithOffer = useMemo(() => allColumns.filter(c => !c.exclusion), [allColumns]);
const excludedColumns = useMemo(
() => allColumns.filter((c): c is typeof c & { exclusion: SupplierExclusion } => c.exclusion !== undefined),
[allColumns],
);
// У поставщика может быть несколько КП (колонок) — подписываем их файлом КП, а в текстах и ИИ
// используем различимые имена «Название (файл)».
// По всем колонкам, включая исключённых: у исключённой колонки поставщика с двумя КП в списке
// «Исключены из сравнения» тоже нужна подпись файла.
const columnLabels = useMemo(
() => buildColumnLabels(suppliersWithOffer, formData.uploadedKpFiles ?? []),
[suppliersWithOffer, formData.uploadedKpFiles],
() => buildColumnLabels(allColumns, formData.uploadedKpFiles ?? []),
[allColumns, formData.uploadedKpFiles],
);
const columnDisplayNames = useMemo(
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels),
[suppliersWithOffer, columnLabels],
() => buildColumnDisplayNames(allColumns, columnLabels),
[allColumns, columnLabels],
);
const allPositions = useMemo((): PositionRow[] => {
@@ -348,35 +360,21 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
return m;
}, [suppliers, offers, extraSuppliers]);
const computedOffers = useMemo((): SupplierOffer[] => {
const result: SupplierOffer[] = [];
suppliersWithOffer.forEach(s => {
allPositions.forEach(pos => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
// For extra suppliers quantity may be undefined in edits (user only entered price).
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
const posQtyFallback = s.isExtraSupplier ? (pos.quantity || undefined) : undefined;
items.forEach(item => result.push(computeOffer(
{
...item,
unit: item.unit || pos.unit,
supplierProductName: item.supplierProductName || pos.materialName,
quantity: item.quantity ?? posQtyFallback,
},
currency, rate
)));
});
});
return result;
}, [edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer]);
const computedOffers = useMemo(
(): SupplierOffer[] => computeColumnsOffers({
columns: suppliersWithOffer, positions: allPositions, edits, supplierCurrencies, currencyRates,
}),
[edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer],
);
const excludedOffers = useMemo(
(): SupplierOffer[] => computeColumnsOffers({
columns: excludedColumns, positions: allPositions, edits, supplierCurrencies, currencyRates,
}),
[edits, supplierCurrencies, currencyRates, allPositions, excludedColumns],
);
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]);
// F5 до «Сохранить» не должен терять и цены исключённых — иначе «Вернуть» вернёт пустую колонку.
React.useEffect(() => { computedOffersRef.current = [...computedOffers, ...excludedOffers]; }, [computedOffers, excludedOffers]);
// Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13).
const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]);
@@ -475,26 +473,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
[isReadOnly]
);
const handleDeleteSupplier = useCallback((supplierId: string, isExtra: boolean): void => {
// Исключение вместо удаления (docs/adr/0001): колонка скрывается, цены остаются в edits.
// undefined — вернуть поставщика в сравнение. Пишем сразу в formData, как статус поставщика.
const setSupplierExclusion = useCallback((supplierId: string, exclusion: SupplierExclusion | undefined): void => {
if (isReadOnly) return;
if (isExtra) {
const updated = extraSuppliers.filter(es => es.id !== supplierId);
setExtraSuppliers(updated);
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ step3ExtraSuppliers: updated });
if (suppliers.some(s => s.id === supplierId)) {
onSave({ suppliers: suppliers.map(s => (s.id === supplierId ? { ...s, exclusion } : s)) });
} else {
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ suppliers: suppliers.filter(s => s.id !== supplierId) });
const updated = extraSuppliers.map(es => (es.id === supplierId ? { ...es, exclusion } : es));
setExtraSuppliers(updated);
onSave({ step3ExtraSuppliers: updated });
}
}, [isReadOnly, extraSuppliers, suppliers, onSave]);
// Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, onSave]);
const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => {
if (!reason.trim()) return;
setSupplierExclusion(supplierId, { reason: reason.trim() });
}, [setSupplierExclusion]);
const handleRestoreSupplier = useCallback((supplierId: string): void => {
setSupplierExclusion(supplierId, undefined);
}, [setSupplierExclusion]);
const updatePosQty = useCallback((positionId: string, value: string): void => {
if (isReadOnly) return;
@@ -800,6 +801,32 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
});
await service.saveOffers(offersToSave, currencyRates);
// Исключённые — в свой список, не в «Оценку поставщиков»: отчёт СТ и INCORE их не видят.
await service.replaceExcludedOffers(ct.id, buildExcludedRecordsForSave(
excludedColumns.map(column => {
const existingSup = suppliers.find(s => s.id === column.id);
return {
id: column.id,
exclusion: column.exclusion,
supplierSpId: existingSup?.spId ?? extraSupSpIds[column.id],
// Как у обычных колонок: файл КП колонки, у старых — по имени, у добавленных на Шаге 3 —
// созданный для них файл-заглушка.
offerFileSpId: existingSup
? (existingSup.kpFileSpId ?? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId)
: extraOfferIds[column.id],
};
}),
excludedOffers,
{
positionSpIdOf: positionId =>
selectedPositions.find(p => p.id === positionId)?.spId ?? extraPosSpIds[positionId],
valutaSpIdOf: code => {
const id = parseInt(currencyRates.find(r => r.code === code)?.id ?? '', 10);
return isNaN(id) ? undefined : id;
},
deliveryKmOf: (positionId, columnId) => deliveryKm[`${positionId}::${columnId}`],
},
));
// «Не получены КП» — к этой СТ, до согласования: шаг 2 могли пройти крошками, минуя его «Сохранить».
await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers));
// «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ.
@@ -826,10 +853,14 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
// render keys use React IDs, so SP IDs in session cause empty cells on remount.
// Keep offerId from offersToSave (order matches: both derived from same filtered computedOffers).
const filteredComputed = computedOffers.filter(o => o.totalWithExtraKzt > 0);
const offersForSession = filteredComputed.map((o, i) => ({
const offersForSession = [
...filteredComputed.map((o, i) => ({
...o,
offerId: offersToSave[i]?.offerId,
}));
})),
// Цены исключённых — тоже в сессию: «Вернуть» после перехода по шагам возвращает их колонку.
...excludedOffers.filter(o => o.totalWithExtraKzt > 0),
];
onSave({
offers: offersForSession,
@@ -849,6 +880,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
name: es.name,
hasOffer: true as const,
status: supplierStatuses[es.id] ?? '',
exclusion: es.exclusion,
})),
],
recommendedSupplierId: selectedSupplierId,
@@ -931,7 +963,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
updateSupplierProductName,
addBundleItem,
removeBundleItem,
handleDeleteSupplier,
excludedColumns,
handleExcludeSupplier,
handleRestoreSupplier,
updatePosQty,
updateCriteriaValue,
updateCustomCriteriaValue,
@@ -1,6 +1,6 @@
import { SupplierOffer } from '../types/domain';
import { IExcludedOfferItem, fromExcludedOfferItem, toExcludedOfferBody } from '../../../../shared/offers/excludedOffers';
import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData } from './excludedColumns';
import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData, keepExclusions } from './excludedColumns';
const REASON = 'Дорого';
const USD_SP_ID = 3;
@@ -58,6 +58,24 @@ describe('excludedItemsToFormData', () => {
});
});
describe('keepExclusions', () => {
// Решение от 2026-10-27: повторная загрузка КП не возвращает поставщика — только «Вернуть».
it('пересобранная колонка того же КП остаётся исключённой', () => {
const previous = [{ id: '259_278', name: 'ООО Альфа', exclusion: { reason: REASON } }];
const rebuilt = [{ id: '259_278', name: 'ООО Альфа', currency: 'USD' }];
expect(keepExclusions(rebuilt, previous)).toEqual([
{ id: '259_278', name: 'ООО Альфа', currency: 'USD', exclusion: { reason: REASON } },
]);
});
it('новый КП того же поставщика — новая колонка, не исключена', () => {
const previous = [{ id: '259_278', name: 'ООО Альфа', exclusion: { reason: REASON } }];
expect(keepExclusions([{ id: '259_300', name: 'ООО Альфа' }], previous)[0].exclusion).toBeUndefined();
});
});
describe('buildExcludedRecordsForSave', () => {
const column = { id: '259_278', exclusion: { reason: REASON }, supplierSpId: 259, offerFileSpId: 278 };
@@ -53,6 +53,16 @@ export function excludedItemsToFormData(
return { suppliers, offers };
}
// Шаг 2 пересобирает колонки КП с нуля (повторная обработка, сохранение шага). Исключение с прежней
// колонки того же КП переносим: повторная загрузка КП не возвращает поставщика в сравнение —
// только «Вернуть» на Шаге 3 (решение от 2026-10-27).
export function keepExclusions(columns: Supplier[], previous: Supplier[]): Supplier[] {
return columns.map(column => {
const exclusion = previous.find(p => p.id === column.id)?.exclusion;
return exclusion ? { ...column, exclusion } : column;
});
}
// Колонка исключённого поставщика на момент сохранения: SP Id поставщика и файла КП уже известны
// (у добавленных на Шаге 3 — после их создания в SP).
export interface ExcludedColumnToSave {