Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 107110b2b8 | |||
| da2c86b4a9 | |||
| 09a0889741 | |||
| 5c21718914 | |||
| 78e5eec9b6 |
@@ -11,6 +11,8 @@
|
||||
|
||||
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
|
||||
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
|
||||
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
|
||||
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
|
||||
|
||||
### Изменено
|
||||
|
||||
@@ -21,9 +23,16 @@
|
||||
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
||||
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
||||
дальше. (#7)
|
||||
- СТ ОМТС, Шаг 3: условия оплаты обязательны для «Сохранить и продолжить» — раньше были помечены
|
||||
обязательными, но сохранять не мешали. (#9)
|
||||
|
||||
### Исправлено
|
||||
|
||||
- СТ ОМТС: у поставщика, добавленного на Шаге 3 вручную, не видно служебного файла `.txt` —
|
||||
ни в списках КП на Шагах 2 и 3, ни в подписи колонки, ни в пакете для INCORE. (#10)
|
||||
- СТ ОМТС, Шаг 3: поставщик, добавленный на шаге и сохранённый черновиком, не задваивается при
|
||||
открытии СТ из реестра. (#8)
|
||||
|
||||
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
||||
«Сохранить и продолжить». (#5)
|
||||
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
||||
|
||||
@@ -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, findWinnerColumnId } from './utils/supplierColumns';
|
||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId, withoutSessionExtras } from './utils/supplierColumns';
|
||||
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||
import { excludedItemsToFormData } from './utils/excludedColumns';
|
||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||
@@ -122,16 +122,22 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||
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,
|
||||
supplierName: o.supplierName,
|
||||
offerFileSpId: o.offerFileSpId,
|
||||
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||
})));
|
||||
}))), sessionExtras);
|
||||
const supplierMap = new Map<number, string>();
|
||||
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
||||
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из 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 =>
|
||||
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
||||
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
||||
|
||||
+5
-2
@@ -14,6 +14,7 @@ import { UploadedKpList } from './UploadedKpList';
|
||||
import { removeKpFileFromComparison } from './kpRemoval';
|
||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||
import styles from './Step2CommercialOffers.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -160,6 +161,8 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
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 uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
|
||||
@@ -201,9 +204,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
|
||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
||||
|
||||
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
|
||||
{!isRestrictedView && realKpFiles.length > 0 && (
|
||||
<UploadedKpList
|
||||
files={formData.uploadedKpFiles}
|
||||
files={realKpFiles}
|
||||
service={service}
|
||||
currencyOptions={currencyOptions}
|
||||
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 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);
|
||||
|
||||
@@ -113,7 +115,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
{!isReadOnly ? (
|
||||
<>
|
||||
<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))}
|
||||
aria-pressed={isSel}
|
||||
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||
|
||||
+8
-3
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
|
||||
import { Step3Matrix } from './useStep3Matrix';
|
||||
import { getUnitStatus } from '../utils/unitConversion';
|
||||
import { unitStatusClass } from './unitStatusStyles';
|
||||
import { isPriceMissing } from './step3Validation';
|
||||
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||
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 dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||
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 (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.deliveryKmBox}>
|
||||
<div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -160,7 +165,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
</td>
|
||||
{isForeign && (
|
||||
<>
|
||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -180,7 +185,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
|
||||
+5
-2
@@ -6,6 +6,7 @@ import { Step3Matrix } from './useStep3Matrix';
|
||||
import { getUnitStatus } from '../utils/unitConversion';
|
||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||
import { unitStatusClass } from './unitStatusStyles';
|
||||
import { isPriceMissing } from './step3Validation';
|
||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||
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 dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||
const showInput = !isReadOnly && !translateUnits;
|
||||
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
|
||||
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
|
||||
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
@@ -160,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
{!isRestrictedView && isForeign && (
|
||||
<>
|
||||
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -180,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
|
||||
+20
@@ -1349,3 +1349,23 @@ $delivery-km-min-width: 76px;
|
||||
align-items: center;
|
||||
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 { ExcludedSuppliersList } from './ExcludedSuppliersList';
|
||||
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||
|
||||
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
|
||||
// решение от 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
|
||||
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', () => {
|
||||
it('нажали «Выбрать» — поставщик выбран', () => {
|
||||
@@ -19,23 +19,44 @@ describe('getStep3SaveError', () => {
|
||||
const compared = ['259_278', '282_320'];
|
||||
|
||||
it('победитель выбран — сохранять можно', () => {
|
||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('победитель не выбран — нельзя', () => {
|
||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||
});
|
||||
|
||||
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Решение от 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 = {
|
||||
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
||||
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
|
||||
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
||||
};
|
||||
|
||||
@@ -9,21 +10,32 @@ export interface Step3SaveCheckInput {
|
||||
selectedSupplierId: string | undefined;
|
||||
// Колонки, которые сравниваются (без исключённых).
|
||||
comparedColumnIds: string[];
|
||||
hasMissingPaymentTerms: boolean;
|
||||
hasDeliveryWithoutKm: boolean;
|
||||
}
|
||||
|
||||
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
|
||||
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
|
||||
// исключённый или удалённый поставщик победителем не считается.
|
||||
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
|
||||
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
|
||||
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
|
||||
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
|
||||
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
|
||||
// - Километраж логистики.
|
||||
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
||||
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
|
||||
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
|
||||
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
||||
return STEP3_SAVE_ERRORS.noWinner;
|
||||
}
|
||||
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
|
||||
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
||||
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 {
|
||||
return currentId === clickedId ? undefined : clickedId;
|
||||
|
||||
@@ -721,6 +721,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const error = getStep3SaveError({
|
||||
selectedSupplierId,
|
||||
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
||||
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
|
||||
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
||||
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 { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||
|
||||
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
||||
export type IncoreStep = 'report' | SigexSignStep;
|
||||
@@ -72,9 +73,10 @@ export function useIncore(args: UseIncoreArgs) {
|
||||
setCardAttachment(saved);
|
||||
|
||||
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
||||
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
|
||||
service.downloadBlob(cardBlob, incoreCardName);
|
||||
|
||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
||||
const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||
for (const f of kpFiles) {
|
||||
await service.downloadKpFile(f.name);
|
||||
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';
|
||||
|
||||
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 (победитель из шапки СТ при открытии без сессии)', () => {
|
||||
const columns: Supplier[] = [
|
||||
{ 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('подписи колонок поставщика с несколькими КП', () => {
|
||||
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||
const columns: Supplier[] = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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());
|
||||
}
|
||||
|
||||
// Поставщик, добавленный на Шаге 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 хранится поставщик,
|
||||
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
||||
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
||||
@@ -75,6 +92,11 @@ export function buildColumnLabels(
|
||||
if (group.length < 2) return;
|
||||
group.forEach((c, index) => {
|
||||
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}`;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user