Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 107110b2b8 | |||
| da2c86b4a9 | |||
| 09a0889741 | |||
| 5c21718914 | |||
| 78e5eec9b6 |
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
|
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
|
||||||
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
|
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
|
||||||
|
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
|
||||||
|
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
|
||||||
|
|
||||||
### Изменено
|
### Изменено
|
||||||
|
|
||||||
@@ -21,9 +23,16 @@
|
|||||||
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
||||||
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
||||||
дальше. (#7)
|
дальше. (#7)
|
||||||
|
- СТ ОМТС, Шаг 3: условия оплаты обязательны для «Сохранить и продолжить» — раньше были помечены
|
||||||
|
обязательными, но сохранять не мешали. (#9)
|
||||||
|
|
||||||
### Исправлено
|
### Исправлено
|
||||||
|
|
||||||
|
- СТ ОМТС: у поставщика, добавленного на Шаге 3 вручную, не видно служебного файла `.txt` —
|
||||||
|
ни в списках КП на Шагах 2 и 3, ни в подписи колонки, ни в пакете для INCORE. (#10)
|
||||||
|
- СТ ОМТС, Шаг 3: поставщик, добавленный на шаге и сохранённый черновиком, не задваивается при
|
||||||
|
открытии СТ из реестра. (#8)
|
||||||
|
|
||||||
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
||||||
«Сохранить и продолжить». (#5)
|
«Сохранить и продолжить». (#5)
|
||||||
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
||||||
|
|||||||
@@ -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, findWinnerColumnId } from './utils/supplierColumns';
|
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId, withoutSessionExtras } 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';
|
||||||
@@ -122,16 +122,22 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||||
const currencyCodeOf = (valutaSpId?: number): string =>
|
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||||
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||||
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
|
// Поставщики Шага 3 из сессии (после «Сохранить черновик») уже лежат в SP — их колонки из SP
|
||||||
|
// не дублируем.
|
||||||
|
const sessionExtras = formData.step3ExtraSuppliers ?? [];
|
||||||
|
const spSuppliers = withoutSessionExtras(buildColumnsFromSpOffers(rawOffers.map(o => ({
|
||||||
supplierSpId: o.supplierSpId,
|
supplierSpId: o.supplierSpId,
|
||||||
supplierName: o.supplierName,
|
supplierName: o.supplierName,
|
||||||
offerFileSpId: o.offerFileSpId,
|
offerFileSpId: o.offerFileSpId,
|
||||||
currencyCode: currencyCodeOf(o.valutaSpId),
|
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||||
})));
|
}))), sessionExtras);
|
||||||
const supplierMap = new Map<number, string>();
|
const supplierMap = new Map<number, string>();
|
||||||
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
||||||
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
||||||
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
|
// Поставщики Шага 3 — тоже колонки: их офферы из SP должны попасть к ним, а не в убранную колонку.
|
||||||
|
const visibleColumns = formData.suppliers.length > 0
|
||||||
|
? formData.suppliers
|
||||||
|
: [...spSuppliers, ...sessionExtras.map(es => ({ id: es.id, spId: es.spId, name: es.name, hasOffer: true }))];
|
||||||
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
||||||
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
||||||
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
||||||
|
|||||||
+5
-2
@@ -14,6 +14,7 @@ import { UploadedKpList } from './UploadedKpList';
|
|||||||
import { removeKpFileFromComparison } from './kpRemoval';
|
import { removeKpFileFromComparison } from './kpRemoval';
|
||||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||||
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -160,6 +161,8 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
|
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
|
||||||
|
// Заглушки поставщиков, добавленных на Шаге 3 вручную, — не КП: в списке не показываем.
|
||||||
|
const realKpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||||
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
|
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
|
||||||
|
|
||||||
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
|
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
|
||||||
@@ -201,9 +204,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
|
|
||||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
||||||
|
|
||||||
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
|
{!isRestrictedView && realKpFiles.length > 0 && (
|
||||||
<UploadedKpList
|
<UploadedKpList
|
||||||
files={formData.uploadedKpFiles}
|
files={realKpFiles}
|
||||||
service={service}
|
service={service}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||||
|
|||||||
+8
-1
@@ -34,6 +34,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
// Колонка, для которой открыто окно «Исключить из сравнения».
|
// Колонка, для которой открыто окно «Исключить из сравнения».
|
||||||
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
|
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
|
||||||
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
|
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
|
||||||
|
// Как в проверке перед сохранением (getStep3SaveError): выбор на исключённом — не победитель.
|
||||||
|
const hasWinner = suppliersWithOffer.some(s => s.id === selectedSupplierId);
|
||||||
|
|
||||||
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
|
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
|
||||||
|
|
||||||
@@ -113,7 +115,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
styles.selectSupplierBtn,
|
||||||
|
isSel ? styles.selectSupplierBtnActive : '',
|
||||||
|
// Победитель не выбран — красная рамка у всех «Выбрать»: без него дальше Шага 3 не пускает.
|
||||||
|
!hasWinner ? styles.selectSupplierBtnRequired : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
||||||
aria-pressed={isSel}
|
aria-pressed={isSel}
|
||||||
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||||
|
|||||||
+8
-3
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
|
|||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { getUnitStatus } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { unitStatusClass } from './unitStatusStyles';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { isPriceMissing } from './step3Validation';
|
||||||
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
@@ -75,6 +76,10 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||||
const showInput = !isReadOnly && !translateUnits;
|
const showInput = !isReadOnly && !translateUnits;
|
||||||
|
// Подсветка сразу, пока заполняют: пустая цена — заливкой (сохранять не мешает),
|
||||||
|
// пустой километраж логистики — рамкой (не пускает дальше Шага 3).
|
||||||
|
const priceMissingCls = !isReadOnly && isPriceMissing(price, item?.priceKztOverride) ? styles.missingPriceCell : '';
|
||||||
|
const kmMissingCls = !isReadOnly && deliveryKm[`${pos.id}::${s.id}`] === undefined ? styles.requiredMissingBox : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
@@ -124,7 +129,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
|
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.deliveryKmBox}>
|
<div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -160,7 +165,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
</td>
|
</td>
|
||||||
{isForeign && (
|
{isForeign && (
|
||||||
<>
|
<>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -180,7 +185,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
+5
-2
@@ -6,6 +6,7 @@ import { Step3Matrix } from './useStep3Matrix';
|
|||||||
import { getUnitStatus } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { unitStatusClass } from './unitStatusStyles';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { isPriceMissing } from './step3Validation';
|
||||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||||
const showInput = !isReadOnly && !translateUnits;
|
const showInput = !isReadOnly && !translateUnits;
|
||||||
|
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
|
||||||
|
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
@@ -160,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
{!isRestrictedView && isForeign && (
|
{!isRestrictedView && isForeign && (
|
||||||
<>
|
<>
|
||||||
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -180,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
+20
@@ -1349,3 +1349,23 @@ $delivery-km-min-width: 76px;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Незаполненное (решение от 2026-10-27: подсвечивать сразу, пока заполняют) ─────
|
||||||
|
// Красная рамка — то, что не пускает дальше Шага 3 (см. step3Validation): победитель, километраж,
|
||||||
|
// условия оплаты (.criteriaInputRequired). Бледная заливка — пустая цена: сохранять не мешает.
|
||||||
|
|
||||||
|
// !important — фон выбранной колонки (.colSelected) тоже с !important, а пустая цена в выбранной
|
||||||
|
// колонке должна оставаться заметной.
|
||||||
|
.missingPriceCell {
|
||||||
|
background: $danger-bg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requiredMissingBox {
|
||||||
|
box-shadow: inset 0 0 0 1px $danger;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectSupplierBtnRequired {
|
||||||
|
border-color: $danger;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -20,6 +20,7 @@ import { useRecommendedPick } from './useRecommendedPick';
|
|||||||
import { VendorPickerDialog } from './VendorPickerDialog';
|
import { VendorPickerDialog } from './VendorPickerDialog';
|
||||||
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
|
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
|
||||||
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
|
|
||||||
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
|
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
|
||||||
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
|
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
|
||||||
@@ -202,7 +203,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
|
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
|
||||||
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />}
|
{/* Заглушки поставщиков, добавленных вручную, — не КП: не показываем. */}
|
||||||
|
{!isRestrictedView && <KpFilesList files={withoutPlaceholders(formData.uploadedKpFiles ?? [])} service={service} />}
|
||||||
|
|
||||||
<AiCommentBlock
|
<AiCommentBlock
|
||||||
comment={ai.aiComment}
|
comment={ai.aiComment}
|
||||||
|
|||||||
+26
-5
@@ -1,4 +1,4 @@
|
|||||||
import { STEP3_SAVE_ERRORS, getStep3SaveError, toggleWinner } from './step3Validation';
|
import { STEP3_SAVE_ERRORS, getStep3SaveError, isPriceMissing, toggleWinner } from './step3Validation';
|
||||||
|
|
||||||
describe('toggleWinner', () => {
|
describe('toggleWinner', () => {
|
||||||
it('нажали «Выбрать» — поставщик выбран', () => {
|
it('нажали «Выбрать» — поставщик выбран', () => {
|
||||||
@@ -19,23 +19,44 @@ describe('getStep3SaveError', () => {
|
|||||||
const compared = ['259_278', '282_320'];
|
const compared = ['259_278', '282_320'];
|
||||||
|
|
||||||
it('победитель выбран — сохранять можно', () => {
|
it('победитель выбран — сохранять можно', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('победитель не выбран — нельзя', () => {
|
it('победитель не выбран — нельзя', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
||||||
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
|
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('логистика без километража — нельзя', () => {
|
it('логистика без километража — нельзя', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: true }))
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: true }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
|
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Решение от 2026-10-27: «Условия оплаты *» были помечены обязательными, но сохранять не мешали.
|
||||||
|
it('условия оплаты не у всех — нельзя', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: true, hasDeliveryWithoutKm: false }))
|
||||||
|
.toBe(STEP3_SAVE_ERRORS.paymentTermsMissing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPriceMissing', () => {
|
||||||
|
it('цены нет ни в валюте, ни в ₸ — пусто', () => {
|
||||||
|
expect(isPriceMissing(undefined, undefined)).toBe(true);
|
||||||
|
expect(isPriceMissing(0, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('цена в валюте КП есть — не пусто', () => {
|
||||||
|
expect(isPriceMissing(635, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('цену ввели вручную в ₸ — не пусто', () => {
|
||||||
|
expect(isPriceMissing(undefined, 286575.5)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-4
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
export const STEP3_SAVE_ERRORS = {
|
export const STEP3_SAVE_ERRORS = {
|
||||||
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
||||||
|
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
|
||||||
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -9,21 +10,32 @@ export interface Step3SaveCheckInput {
|
|||||||
selectedSupplierId: string | undefined;
|
selectedSupplierId: string | undefined;
|
||||||
// Колонки, которые сравниваются (без исключённых).
|
// Колонки, которые сравниваются (без исключённых).
|
||||||
comparedColumnIds: string[];
|
comparedColumnIds: string[];
|
||||||
|
hasMissingPaymentTerms: boolean;
|
||||||
hasDeliveryWithoutKm: boolean;
|
hasDeliveryWithoutKm: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
|
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
|
||||||
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
|
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
|
||||||
// исключённый или удалённый поставщик победителем не считается.
|
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
|
||||||
|
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
|
||||||
|
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
|
||||||
|
// - Километраж логистики.
|
||||||
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
||||||
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
|
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
|
||||||
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
||||||
return STEP3_SAVE_ERRORS.noWinner;
|
return STEP3_SAVE_ERRORS.noWinner;
|
||||||
}
|
}
|
||||||
|
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
|
||||||
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Цена в ячейке не введена: ни в валюте КП, ни вручную в ₸. Подсвечивается, но сохранению не
|
||||||
|
// мешает — поставщик мог не предложить позицию, и 0 вместо пустоты исказил бы сравнение.
|
||||||
|
export function isPriceMissing(price: number | undefined, priceKztOverride: number | undefined): boolean {
|
||||||
|
return !(price !== undefined && price > 0) && !(priceKztOverride !== undefined && priceKztOverride > 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
|
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
|
||||||
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
|
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
|
||||||
return currentId === clickedId ? undefined : clickedId;
|
return currentId === clickedId ? undefined : clickedId;
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const error = getStep3SaveError({
|
const error = getStep3SaveError({
|
||||||
selectedSupplierId,
|
selectedSupplierId,
|
||||||
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
||||||
|
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
|
||||||
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
||||||
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
|
|
||||||
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
||||||
export type IncoreStep = 'report' | SigexSignStep;
|
export type IncoreStep = 'report' | SigexSignStep;
|
||||||
@@ -72,9 +73,10 @@ export function useIncore(args: UseIncoreArgs) {
|
|||||||
setCardAttachment(saved);
|
setCardAttachment(saved);
|
||||||
|
|
||||||
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
||||||
|
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
|
||||||
service.downloadBlob(cardBlob, incoreCardName);
|
service.downloadBlob(cardBlob, incoreCardName);
|
||||||
|
|
||||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||||
for (const f of kpFiles) {
|
for (const f of kpFiles) {
|
||||||
await service.downloadKpFile(f.name);
|
await service.downloadKpFile(f.name);
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { isPlaceholderKpFile, withoutPlaceholders } from './placeholderKpFile';
|
||||||
|
|
||||||
|
describe('isPlaceholderKpFile', () => {
|
||||||
|
// СТ 375: поставщик «fghfg» добавлен на Шаге 3 — заглушка видна в «Коммерческих предложениях».
|
||||||
|
it('заглушка поставщика, добавленного вручную', () => {
|
||||||
|
expect(isPlaceholderKpFile('fghfg_1793100061349.txt')).toBe(true);
|
||||||
|
expect(isPlaceholderKpFile('ООО Альфа_1793100061349.TXT')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('настоящие КП — не заглушки', () => {
|
||||||
|
expect(isPlaceholderKpFile('372_KP_SteelGate_Export_USD.pdf')).toBe(false);
|
||||||
|
expect(isPlaceholderKpFile('372_KP_TOO_Kaspiy_Dors.docx')).toBe(false);
|
||||||
|
expect(isPlaceholderKpFile('КП.doc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withoutPlaceholders', () => {
|
||||||
|
it('оставляет только настоящие КП, порядок сохраняется', () => {
|
||||||
|
const files = [
|
||||||
|
{ spId: 318, name: '372_KP_SteelGate.pdf' },
|
||||||
|
{ spId: 322, name: 'fghfg_1793100061349.txt' },
|
||||||
|
{ spId: 320, name: '372_KP_Kaspiy.docx' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(withoutPlaceholders(files).map(f => f.spId)).toEqual([318, 320]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Файл-заглушка КП: создаётся для поставщика, добавленного на Шаге 3 вручную, — оффер в SP должен
|
||||||
|
// ссылаться на файл КП (CtService._createPlaceholderKpFile). Людям он не нужен: в списках КП и в
|
||||||
|
// пакете INCORE его не показываем. Узнаём по расширению: на Шаге 2 загрузить можно только
|
||||||
|
// .pdf/.docx/.doc (FileUploadArea), а заглушка всегда .txt.
|
||||||
|
const PLACEHOLDER_EXTENSION = '.txt';
|
||||||
|
|
||||||
|
// Подпись колонки поставщика, у которого вместо КП — заглушка.
|
||||||
|
export const PLACEHOLDER_COLUMN_LABEL = 'Добавлен вручную';
|
||||||
|
|
||||||
|
export function isPlaceholderKpFile(fileName: string): boolean {
|
||||||
|
return fileName.toLowerCase().endsWith(PLACEHOLDER_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настоящие КП — для показа и выгрузки. Данные формы (uploadedKpFiles) не фильтруем: по ним
|
||||||
|
// сохранение находит файл КП колонки, в том числе заглушку.
|
||||||
|
export function withoutPlaceholders<T extends { name: string }>(files: T[]): T[] {
|
||||||
|
return files.filter(file => !isPlaceholderKpFile(file.name));
|
||||||
|
}
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId } from './supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId, withoutSessionExtras } from './supplierColumns';
|
||||||
import { Supplier } from '../types/domain';
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
describe('withoutSessionExtras (поставщик Шага 3 после «Сохранить черновик»)', () => {
|
||||||
|
// СТ 375: единственный поставщик «fghfg» добавлен на Шаге 3, сохранён черновиком. При открытии из
|
||||||
|
// реестра колонка собиралась из SP («285_322», без цен) и ещё раз приходила из сессии (с ценами).
|
||||||
|
const fromSp: Supplier[] = [
|
||||||
|
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||||
|
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('колонку поставщика Шага 3 из SP не добавляет — она уже есть в сессии', () => {
|
||||||
|
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 322 }]);
|
||||||
|
|
||||||
|
expect(result.map(c => c.id)).toEqual(['282_320']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('другой КП того же поставщика — отдельная колонка, остаётся', () => {
|
||||||
|
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 999 }]);
|
||||||
|
|
||||||
|
expect(result.map(c => c.id)).toEqual(['285_322', '282_320']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поставщик Шага 3 ещё не сохранён в SP — ничего не убирает', () => {
|
||||||
|
expect(withoutSessionExtras(fromSp, [{}])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
||||||
const columns: Supplier[] = [
|
const columns: Supplier[] = [
|
||||||
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
||||||
@@ -68,6 +93,19 @@ describe('buildColumnsFromSpOffers (открытие по ссылке / без
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('подпись колонки с файлом-заглушкой', () => {
|
||||||
|
// СТ 375: в шапке колонки показывалось имя заглушки «fghfg_1793100061349.txt».
|
||||||
|
it('вместо имени заглушки — «Добавлен вручную»', () => {
|
||||||
|
const columns: Supplier[] = [
|
||||||
|
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||||
|
{ id: '285_330', spId: 285, kpFileSpId: 330, name: 'fghfg', hasOffer: true },
|
||||||
|
];
|
||||||
|
const files = [{ spId: 322, name: 'fghfg_1793100061349.txt' }, { spId: 330, name: '375_KP_fghfg.pdf' }];
|
||||||
|
|
||||||
|
expect(buildColumnLabels(columns, files)).toEqual({ '285_322': 'Добавлен вручную', '285_330': '375_KP_fghfg.pdf' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('подписи колонок поставщика с несколькими КП', () => {
|
describe('подписи колонок поставщика с несколькими КП', () => {
|
||||||
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||||
const columns: Supplier[] = [
|
const columns: Supplier[] = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Supplier } from '../types/domain';
|
import { Supplier } from '../types/domain';
|
||||||
|
import { PLACEHOLDER_COLUMN_LABEL, isPlaceholderKpFile } from './placeholderKpFile';
|
||||||
|
|
||||||
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
||||||
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
||||||
@@ -44,6 +45,22 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
|
|||||||
return Array.from(columnsById.values());
|
return Array.from(columnsById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поставщик, добавленный на Шаге 3, в сессии: SP Id поставщика и его файла-заглушки КП.
|
||||||
|
export interface SessionExtraColumn {
|
||||||
|
spId?: number;
|
||||||
|
offerId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки из SP без тех, что в сессии уже есть поставщиком Шага 3. «Сохранить черновик» оставляет
|
||||||
|
// такого поставщика в списке Шага 3 (см. buildSuppliersPatch), а в SP он уже лежит колонкой
|
||||||
|
// «поставщик_заглушка». Без этого при открытии СТ, где других поставщиков нет, колонка задваивалась:
|
||||||
|
// одна из SP без цен, вторая из сессии с ценами.
|
||||||
|
export function withoutSessionExtras(spColumns: Supplier[], extras: SessionExtraColumn[]): Supplier[] {
|
||||||
|
return spColumns.filter(column => !extras.some(extra =>
|
||||||
|
extra.spId !== undefined && extra.spId === column.spId && extra.offerId === column.kpFileSpId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
||||||
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
||||||
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
||||||
@@ -75,6 +92,11 @@ export function buildColumnLabels(
|
|||||||
if (group.length < 2) return;
|
if (group.length < 2) return;
|
||||||
group.forEach((c, index) => {
|
group.forEach((c, index) => {
|
||||||
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
||||||
|
// Имя файла-заглушки (поставщик добавлен вручную) людям ничего не говорит.
|
||||||
|
if (fileName !== undefined && isPlaceholderKpFile(fileName)) {
|
||||||
|
labels[c.id] = PLACEHOLDER_COLUMN_LABEL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user