fix(ct): файлы КП сохраняются под латинским именем

Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени
(OCR 415 «unsupported file type ''») и отвечает 500 — КП падало при
каждой первой загрузке. Повторная загрузка «проходила» лишь потому, что
текст бэкенд успевал записать, и фронт пропускал извлечение.

Имя в библиотеке КП транслитерируется, остальные символы — в «_».
Результат детерминированный: поиск уже загруженного файла и проверка
дублей работают как раньше.
This commit is contained in:
Тимур Городецкий
2026-10-22 09:20:56 +05:00
parent 61913ee79b
commit 5382459414
2 changed files with 70 additions and 6 deletions
@@ -4,14 +4,44 @@ describe('buildKpStorageName (имя файла КП в библиотеке)',
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой, // КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным. // и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
it('добавляет префикс СТ', () => { it('добавляет префикс СТ', () => {
expect(buildKpStorageName('357', 'КП.pdf')).toBe('357_КП.pdf'); expect(buildKpStorageName('357', 'KP.pdf')).toBe('357_KP.pdf');
}); });
it('не добавляет префикс второй раз', () => { it('не добавляет префикс второй раз', () => {
expect(buildKpStorageName('357', '357_КП.pdf')).toBe('357_КП.pdf'); expect(buildKpStorageName('357', '357_KP.pdf')).toBe('357_KP.pdf');
}); });
it('без СТ — исходное имя', () => { it('без СТ — имя без префикса', () => {
expect(buildKpStorageName(undefined, 'КП.pdf')).toBe('КП.pdf'); expect(buildKpStorageName(undefined, 'KP.pdf')).toBe('KP.pdf');
});
});
describe('buildKpStorageName — только латиница', () => {
// СТ 365, файл 306: PDF с кириллицей в имени → extractPDFText 500 (OCR 415 «unsupported file type ''»),
// тот же PDF с латинским именем проходит. Бэкенд не наш — храним файл под латинским именем.
it('транслитерирует кириллицу', () => {
expect(buildKpStorageName('365', 'КП_тест_кириллица.pdf')).toBe('365_KP_test_kirillitsa.pdf');
});
it('транслитерирует заглавные и казахские буквы', () => {
expect(buildKpStorageName('274', 'Қорғаныш_Әлем_Щит.pdf')).toBe('274_Qorganysh_Alem_Shchit.pdf');
});
it('пробелы, №, кавычки и тире — в безопасные символы', () => {
expect(buildKpStorageName('363', 'Счет №1891 ТОО «Орда» — копия (2).pdf'))
.toBe('363_Schet_N1891_TOO_Orda_kopiya_(2).pdf');
});
it('латинское имя не меняется', () => {
expect(buildKpStorageName('364', 'KP_02_Qurylys-Material_KZT.pdf')).toBe('364_KP_02_Qurylys-Material_KZT.pdf');
});
it('уже сохранённое имя при повторном вызове не меняется (по нему ищем загруженный файл)', () => {
const stored = buildKpStorageName('274', 'Северный мост.pdf');
expect(buildKpStorageName('274', stored)).toBe(stored);
});
it('расширение сохраняется', () => {
expect(buildKpStorageName('1', 'КП.PDF')).toBe('1_KP.PDF');
}); });
}); });
@@ -1,8 +1,42 @@
// Транслитерация кириллицы (русский + казахский) для имени файла в SP.
const TRANSLIT: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y',
к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
ә: 'a', ғ: 'g', қ: 'q', ң: 'n', ө: 'o', ұ: 'u', ү: 'u', һ: 'h', і: 'i',
'№': 'N',
};
// Всё, кроме этих символов, заменяем на «_».
const UNSAFE_CHARS = /[^A-Za-z0-9_.()-]/g;
function transliterateChar(char: string): string {
const lower = char.toLowerCase();
const mapped = TRANSLIT[lower];
if (mapped === undefined) return char;
// Заглавная буква: «Щ» → «Shch», а не «SHCH».
if (char !== lower && mapped.length > 0) return mapped[0].toUpperCase() + mapped.slice(1);
return mapped;
}
// Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени (OCR 415 «unsupported file type ''»)
// и отвечает 500 — КП падало при каждой первой загрузке. Бэкенд не наш, поэтому храним файл под именем
// только из латиницы, цифр и «_ - . ( )». Результат детерминированный: по нему ищем уже загруженный файл.
function toSafeFileName(name: string): string {
return Array.from(name)
.map(transliterateChar)
.join('')
.replace(UNSAFE_CHARS, '_')
.replace(/_+/g, '_')
.replace(/_+\./g, '.');
}
// Имя файла КП в библиотеке «Коммерческие предложения» — с префиксом СТ. Файлы лежат в корне библиотеки // Имя файла КП в библиотеке «Коммерческие предложения» — с префиксом СТ. Файлы лежат в корне библиотеки
// и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и // и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени. // согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
export function buildKpStorageName(ctId: string | undefined, originalName: string): string { export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
if (!ctId) return originalName; const safeName = toSafeFileName(originalName);
if (!ctId) return safeName;
const prefix = `${ctId}_`; const prefix = `${ctId}_`;
return originalName.startsWith(prefix) ? originalName : `${prefix}${originalName}`; return safeName.startsWith(prefix) ? safeName : `${prefix}${safeName}`;
} }