Files
iistwin/server/documents/docx-importer.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

218 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import mammoth from "mammoth";
import fs from "fs/promises";
import path from "path";
import { uploadToS3, uploadToS3FromFile } from "../utils/s3";
import * as storage from "../storage/documents.storage";
import { db } from "../db";
import { fileUploads } from "@shared/schema";
import { DocumentTemplateService } from "./template.service";
import type { MulterFile } from "./asset.service";
import { convertEmfToPng } from "./external-pdf.service";
export interface ImportDocxOptions {
name?: string;
description?: string;
categoryId?: number;
formId?: number;
}
export class DocxImporterService {
constructor(private templateService: DocumentTemplateService) {}
async importDocx(
file: MulterFile,
organizationId: number,
userId: number,
options: ImportDocxOptions = {},
) {
if (!file.path) {
throw new Error("Файл не сохранён на диск");
}
const templateName = options.name || this.sanitizeName(file.originalname);
const { html, sourceDocxFileKey, warnings } = await this.processDocxFile(
file,
templateName,
organizationId,
userId,
);
const template = await this.templateService.create({
organizationId,
name: templateName,
description: options.description,
categoryId: options.categoryId,
formId: options.formId,
content: html,
status: "draft",
pageOrientation: "portrait",
pageSize: "A4",
margins: { top: 1440, left: 1440, right: 1440, bottom: 1440 },
assetPlacements: [],
sourceDocxFileKey,
createdBy: userId,
});
const variables = await this.templateService.autoCreateVariablesFromContent(
template.id,
organizationId,
);
return {
template: { ...template, variables },
variables,
warnings,
};
}
async replaceDocx(
file: MulterFile,
templateId: number,
organizationId: number,
userId: number,
) {
if (!file.path) {
throw new Error("Файл не сохранён на диск");
}
const template = await this.templateService.getByIdRaw(templateId, organizationId);
if (!template) throw new Error("Шаблон не найден");
const templateName = this.sanitizeName(file.originalname);
const { html, sourceDocxFileKey, warnings } = await this.processDocxFile(
file,
templateName,
organizationId,
userId,
);
await this.templateService.update(templateId, organizationId, {
name: template.name,
content: html,
sourceDocxFileKey,
});
const variables = await this.templateService.autoCreateVariablesFromContent(
templateId,
organizationId,
);
const updated = await this.templateService.getById(templateId, organizationId);
return {
template: updated!,
variables,
warnings,
};
}
private async processDocxFile(
file: MulterFile,
templateName: string,
organizationId: number,
userId: number,
): Promise<{ html: string; sourceDocxFileKey: string; warnings: string[] }> {
const result = await mammoth.convertToHtml(
{ path: file.path },
{
convertImage: mammoth.images.imgElement(async (image) => {
const buffer = await image.read("base64");
let binary = Buffer.from(buffer, "base64");
let contentType = image.contentType || "image/png";
let ext = this.extFromContentType(contentType);
// Конвертируем EMF/WMF в PNG через внешний document-worker
if (contentType === "image/x-emf" || contentType === "image/x-wmf") {
const png = await convertEmfToPng(binary);
if (!png) {
throw new Error("Сервис конвертации изображений временно недоступен. Не удалось конвертировать EMF/WMF в PNG.");
}
binary = png;
contentType = "image/png";
ext = "png";
}
const originalName = `image-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const { key } = await uploadToS3(binary, originalName, contentType);
await storage.createOrganizationAsset({
organizationId,
type: "image",
label: originalName,
fileKey: key,
userId,
});
await db.insert(fileUploads).values({
organizationId,
uploadedBy: userId,
fileKey: key,
originalName,
sizeBytes: binary.length,
taskId: null,
fieldId: null,
}).onConflictDoNothing();
return { src: `/api/files/${key}` };
}),
},
);
const html = this.sanitizeHtml(result.value);
// Сохраняем исходный DOCX как шаблон
const { key: sourceDocxFileKey } = await uploadToS3FromFile(
file.path,
`${templateName}.docx`,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
await fs.unlink(file.path).catch(() => {});
return {
html,
sourceDocxFileKey,
warnings: result.messages.map((m) => m.message),
};
}
private sanitizeName(originalname: string): string {
const base = path.basename(originalname, path.extname(originalname));
return base.replace(/[\/\\?%*:|<>"]/g, "-").trim() || "Импортированный шаблон";
}
private extFromContentType(contentType: string | undefined): string {
switch (contentType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/gif":
return "gif";
case "image/webp":
return "webp";
case "image/svg+xml":
return "svg";
default:
return "png";
}
}
private sanitizeHtml(html: string): string {
// mammoth может генерировать <img> без закрывающего слеша и с \n в src.
// Приводим к виду, удобному для TipTap.
return html
.replace(/\s+/g, " ")
.replace(/<img\s+([^>]*?)>/gi, (match, attrs) => {
const srcMatch = /src="([^"]+)"/.exec(attrs);
const src = srcMatch ? srcMatch[1] : "";
const altMatch = /alt="([^"]*)"/.exec(attrs);
const alt = altMatch ? altMatch[1] : "";
return `<img src="${src}" alt="${alt}">`;
})
.trim();
}
}