Compare commits

...

2 Commits

Author SHA1 Message Date
Тимур Городецкий 9da03f931c docs: CHANGELOG — выбор победителя (#7)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:15:52 +05:00
Тимур Городецкий 952a7ed42d fix(ct): выбор победителя снимается, без победителя СТ не сохраняется (#7)
Кнопку «Выбран» нельзя было снять. А если победитель не выбран, «Сохранить»
не трогала winSupplier — в шапке СТ оставался прежний (так же при исключении
выбранного поставщика, #1). Теперь «Выбрать» — переключатель, победитель
обязателен: «Сохранить и продолжить» без него не пускает, проверка — до
диалога комментария ИИ. При открытии СТ без сессии выбор восстанавливается из
winSupplier шапки, иначе в другом браузере пришлось бы выбирать заново.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:15:44 +05:00
11 changed files with 151 additions and 19 deletions
+4
View File
@@ -14,6 +14,8 @@
список пока не показывается. (#1) список пока не показывается. (#1)
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах - СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
периода. Старую потребность можно найти, не загружая весь период. (#5) периода. Старую потребность можно найти, не загружая весь период. (#5)
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
дальше. (#7)
### Исправлено ### Исправлено
@@ -21,3 +23,5 @@
«Сохранить и продолжить». (#5) «Сохранить и продолжить». (#5)
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не - СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6) до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
- СТ ОМТС, Шаг 3: выбор победителя снимается повторным нажатием на «Выбран». При открытии СТ
в другом браузере выбранный победитель сохраняется. (#7)
@@ -9,7 +9,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers'; import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable'; import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval'; import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers'; import { buildOffersFromSpRecords } from './utils/spOffers';
import { excludedItemsToFormData } from './utils/excludedColumns'; import { excludedItemsToFormData } from './utils/excludedColumns';
import { isUserInApprovalChain } from './utils/approvalAccess'; import { isUserInApprovalChain } from './utils/approvalAccess';
@@ -184,18 +184,26 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
} }
} }
// 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).
const suppliers = withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
);
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
// в другом браузере пришлось бы выбирать заново.
const winnerColumnId = formData.recommendedSupplierId === undefined
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
: undefined;
updateFormData({ updateFormData({
ct, ct,
approvalMatrix, approvalMatrix,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID), // Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3. // which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}), ...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits. suppliers,
// Only use SP-derived data when session has none, or in review mode (always fresh). ...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
suppliers: withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
),
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}), ...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}), ...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}), ...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
@@ -8,6 +8,7 @@ import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers'
import { UseAdataCheckResult } from './useAdataCheck'; import { UseAdataCheckResult } from './useAdataCheck';
import { AdataIndicator } from './AdataIndicator'; import { AdataIndicator } from './AdataIndicator';
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog'; import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
import { toggleWinner } from './step3Validation';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
@@ -113,8 +114,9 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
<> <>
<button <button
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')} className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(s.id)} onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
aria-pressed={isSel} aria-pressed={isSel}
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
> >
{isSel ? 'Выбран' : 'Выбрать'} {isSel ? 'Выбран' : 'Выбрать'}
</button> </button>
@@ -56,7 +56,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete }); const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
const { const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency, isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
validateBeforeSave, handleSave,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId, extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier, excludedColumns, handleRestoreSupplier,
} = matrix; } = matrix;
@@ -242,7 +243,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => (!ai.aiComment && !ai.isAiLoading) ? ai.setShowAiPromptDialog(true) : handleSave()} onClick={() => {
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
if (!validateBeforeSave()) return;
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
else void handleSave();
}}
disabled={isSaving || isReadOnly} disabled={isSaving || isReadOnly}
> >
{isSaving ? ( {isSaving ? (
@@ -0,0 +1,41 @@
import { STEP3_SAVE_ERRORS, getStep3SaveError, toggleWinner } from './step3Validation';
describe('toggleWinner', () => {
it('нажали «Выбрать» — поставщик выбран', () => {
expect(toggleWinner(undefined, '259_278')).toBe('259_278');
});
// Раньше повторное нажатие ничего не делало — снять выбор было нельзя.
it('повторное нажатие на «Выбран» снимает выбор', () => {
expect(toggleWinner('259_278', '259_278')).toBeUndefined();
});
it('нажали «Выбрать» у другого — выбор переходит к нему', () => {
expect(toggleWinner('259_278', '282_320')).toBe('282_320');
});
});
describe('getStep3SaveError', () => {
const compared = ['259_278', '282_320'];
it('победитель выбран — сохранять можно', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
.toBeUndefined();
});
it('победитель не выбран — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
it('логистика без километража — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: true }))
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
});
});
@@ -0,0 +1,30 @@
// Проверки перед «Сохранить и продолжить» на Шаге 3. Сообщения — для пользователя.
export const STEP3_SAVE_ERRORS = {
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
};
export interface Step3SaveCheckInput {
selectedSupplierId: string | undefined;
// Колонки, которые сравниваются (без исключённых).
comparedColumnIds: string[];
hasDeliveryWithoutKm: boolean;
}
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
// исключённый или удалённый поставщик победителем не считается.
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
return STEP3_SAVE_ERRORS.noWinner;
}
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
return undefined;
}
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
return currentId === clickedId ? undefined : clickedId;
}
@@ -7,6 +7,7 @@ import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, Cr
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns'; import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput'; import { parseNumberInput } from '../utils/parseNumberInput';
import { getStep3SaveError } from './step3Validation';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -705,15 +706,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions }); onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
}; };
// Проверка перед «Сохранить и продолжить». Показывает ошибку и говорит, можно ли сохранять.
// Вызывается и до диалога «Комментарий от ИИ» — чтобы не гонять пользователя через ИИ к отказу.
const validateBeforeSave = (): boolean => {
const error = getStep3SaveError({
selectedSupplierId,
comparedColumnIds: suppliersWithOffer.map(s => s.id),
hasDeliveryWithoutKm: extraPositions.some(ep =>
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
),
});
setSaveError(error);
return error === undefined;
};
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
if (!ct) return; if (!ct) return;
const deliveryMissingKm = extraPositions.filter(ep => if (!validateBeforeSave()) return;
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
);
if (deliveryMissingKm.length > 0) {
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
return;
}
setIsSaving(true); setIsSaving(true);
setSaveError(undefined); setSaveError(undefined);
@@ -980,6 +989,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleAddDelivery, handleAddDelivery,
handleDeliveryKmChange, handleDeliveryKmChange,
handleDeleteExtraPosition, handleDeleteExtraPosition,
validateBeforeSave,
handleSave, handleSave,
}; };
} }
@@ -48,6 +48,8 @@ export interface CT {
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API) // блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки. // инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
authorEmail?: string; authorEmail?: string;
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
winSupplierSpId?: number;
} }
export type CTStatus = export type CTStatus =
@@ -1,6 +1,27 @@
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId } from './supplierColumns';
import { Supplier } from '../types/domain'; import { Supplier } from '../types/domain';
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
const columns: Supplier[] = [
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
{ id: '281_330', spId: 281, kpFileSpId: 330, name: 'SteelGate', hasOffer: true },
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
];
it('колонка победителя по SP Id поставщика', () => {
expect(findWinnerColumnId(columns, 282)).toBe('282_320');
});
it('у поставщика с несколькими КП — первая неисключённая колонка', () => {
expect(findWinnerColumnId(columns, 281)).toBe('281_330');
});
it('победителя нет или его колонки нет — undefined', () => {
expect(findWinnerColumnId(columns, undefined)).toBeUndefined();
expect(findWinnerColumnId(columns, 999)).toBeUndefined();
});
});
describe('buildSupplierColumnId', () => { describe('buildSupplierColumnId', () => {
it('колонка = поставщик + файл КП', () => { it('колонка = поставщик + файл КП', () => {
expect(buildSupplierColumnId(263, 277)).toBe('263_277'); expect(buildSupplierColumnId(263, 277)).toBe('263_277');
@@ -44,6 +44,13 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
return Array.from(columnsById.values()); return Array.from(columnsById.values());
} }
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
if (winSupplierSpId === undefined) return undefined;
return columns.find(c => c.spId === winSupplierSpId && c.hasOffer !== false && !c.exclusion)?.id;
}
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые // Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
// принадлежат компании, а не КП: проверка Adata, БИН. // принадлежат компании, а не КП: проверка Adata, БИН.
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] { export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
@@ -819,7 +819,7 @@ export class CtService {
} }
async loadCTById(ctId: string): Promise<CT> { async loadCTById(ctId: string): Promise<CT> {
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,Author/EMail&$expand=Author`; const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,winSupplierId,Author/EMail&$expand=Author`;
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();
@@ -829,6 +829,7 @@ export class CtService {
projectName: String(data.project ?? ""), projectName: String(data.project ?? ""),
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed", status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
authorEmail: String(data.Author?.EMail ?? "") || undefined, authorEmail: String(data.Author?.EMail ?? "") || undefined,
winSupplierSpId: data.winSupplierId ? Number(data.winSupplierId) : undefined,
}; };
} }