Compare commits

...

5 Commits

Author SHA1 Message Date
Тимур Городецкий 107110b2b8 docs: CHANGELOG — заглушки КП и двойная колонка (#8, #10)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:03:06 +05:00
Тимур Городецкий da2c86b4a9 fix(ct): файлы-заглушки КП не видны в списках и не идут в пакет INCORE (#10)
Поставщику, добавленному на Шаге 3 вручную, система создаёт в библиотеке КП
файл-заглушку .txt — оффер в SP должен ссылаться на файл КП. Он показывался
в «Коммерческих предложениях», в «Загруженных КП» Шага 2, в подписи колонки
и скачивался в пакет INCORE. Теперь при показе и выгрузке заглушки скрыты
(узнаём по .txt — на Шаге 2 загружают только pdf/docx/doc), в подписи колонки —
«Добавлен вручную». Данные формы не меняются: по ним сохранение находит файл.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:02:58 +05:00
Тимур Городецкий 09a0889741 fix(ct): поставщик Шага 3 после черновика не задваивается при открытии СТ (#8)
«Сохранить черновик» оставляет поставщика, добавленного на Шаге 3, в списке
шага, а в SP он уже лежит колонкой «поставщик_заглушка». Если других
поставщиков в СТ нет, при открытии из реестра колонки собирались из SP — и
поставщик приходил дважды: из SP без цен и из сессии с ценами (подписи
колонок именем файла-заглушки .txt и «КП 2»). Теперь колонки из SP, которые
уже есть поставщиком Шага 3, не добавляются, а их офферы идут к нему.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:52:37 +05:00
Тимур Городецкий 5c21718914 docs: CHANGELOG — подсветка незаполненного (#9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:43:39 +05:00
Тимур Городецкий 78e5eec9b6 feat(ct): подсветка незаполненного на Шаге 3, условия оплаты обязательны (#9)
Красная рамка — то, что не пускает дальше Шага 3: «Выбрать», пока нет
победителя, пустой километраж логистики, пустые условия оплаты. Условия
оплаты были помечены обязательными, но сохранять не мешали — теперь
проверяются. Пустая цена — бледной заливкой: заметно, но сохранению не мешает,
поставщик мог не предложить позицию. Подсветка видна сразу, пока заполняют.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:43:31 +05:00
16 changed files with 220 additions and 24 deletions
+9
View File
@@ -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);
@@ -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)}
@@ -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 ? 'Снять выбор' : 'Выбрать победителем'}
@@ -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
@@ -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
@@ -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;
}
@@ -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}
@@ -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);
});
});
@@ -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}`;
});
});