Compare commits
2 Commits
90b009dfc9
...
9da03f931c
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da03f931c | |||
| 952a7ed42d |
@@ -14,6 +14,8 @@
|
||||
список пока не показывается. (#1)
|
||||
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
|
||||
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
||||
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
||||
дальше. (#7)
|
||||
|
||||
### Исправлено
|
||||
|
||||
@@ -21,3 +23,5 @@
|
||||
«Сохранить и продолжить». (#5)
|
||||
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
||||
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
|
||||
- СТ ОМТС, Шаг 3: выбор победителя снимается повторным нажатием на «Выбран». При открытии СТ
|
||||
в другом браузере выбранный победитель сохраняется. (#7)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||
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 { excludedItemsToFormData } from './utils/excludedColumns';
|
||||
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({
|
||||
ct,
|
||||
approvalMatrix,
|
||||
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
||||
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
||||
...(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, ...excluded.suppliers] : formData.suppliers,
|
||||
noOfferFromSp,
|
||||
),
|
||||
suppliers,
|
||||
...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
|
||||
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
|
||||
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
||||
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
||||
|
||||
+3
-1
@@ -8,6 +8,7 @@ import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers'
|
||||
import { UseAdataCheckResult } from './useAdataCheck';
|
||||
import { AdataIndicator } from './AdataIndicator';
|
||||
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
|
||||
import { toggleWinner } from './step3Validation';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
||||
@@ -113,8 +114,9 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
<>
|
||||
<button
|
||||
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setSelectedSupplierId(s.id)}
|
||||
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
||||
aria-pressed={isSel}
|
||||
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||
>
|
||||
{isSel ? 'Выбран' : 'Выбрать'}
|
||||
</button>
|
||||
|
||||
+8
-2
@@ -56,7 +56,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
||||
const {
|
||||
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,
|
||||
excludedColumns, handleRestoreSupplier,
|
||||
} = matrix;
|
||||
@@ -242,7 +243,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
</Button>
|
||||
<Button
|
||||
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}
|
||||
>
|
||||
{isSaving ? (
|
||||
|
||||
+41
@@ -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);
|
||||
});
|
||||
});
|
||||
+30
@@ -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;
|
||||
}
|
||||
+17
-7
@@ -7,6 +7,7 @@ import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, Cr
|
||||
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
||||
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||
import { getStep3SaveError } from './step3Validation';
|
||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||
@@ -705,15 +706,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
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> => {
|
||||
if (!ct) return;
|
||||
const deliveryMissingKm = extraPositions.filter(ep =>
|
||||
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
||||
);
|
||||
if (deliveryMissingKm.length > 0) {
|
||||
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
|
||||
return;
|
||||
}
|
||||
if (!validateBeforeSave()) return;
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
|
||||
@@ -980,6 +989,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
handleAddDelivery,
|
||||
handleDeliveryKmChange,
|
||||
handleDeleteExtraPosition,
|
||||
validateBeforeSave,
|
||||
handleSave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CT {
|
||||
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
|
||||
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
|
||||
authorEmail?: string;
|
||||
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
|
||||
winSupplierSpId?: number;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
it('колонка = поставщик + файл КП', () => {
|
||||
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
||||
|
||||
@@ -44,6 +44,13 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
|
||||
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 поставщика), включая саму колонку. Нужно для данных, которые
|
||||
// принадлежат компании, а не КП: проверка Adata, БИН.
|
||||
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
|
||||
|
||||
@@ -819,7 +819,7 @@ export class CtService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -829,6 +829,7 @@ export class CtService {
|
||||
projectName: String(data.project ?? ""),
|
||||
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
|
||||
authorEmail: String(data.Author?.EMail ?? "") || undefined,
|
||||
winSupplierSpId: data.winSupplierId ? Number(data.winSupplierId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user