- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
218 lines
6.4 KiB
TypeScript
218 lines
6.4 KiB
TypeScript
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();
|
||
}
|
||
} |