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>
This commit is contained in:
Тимур Городецкий
2026-10-27 17:02:58 +05:00
parent 09a0889741
commit da2c86b4a9
7 changed files with 75 additions and 4 deletions
@@ -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)}
@@ -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}
@@ -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));
}
@@ -93,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';
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
@@ -91,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}`;
});
});