fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
218
server/documents/docx-importer.service.ts
Normal file
218
server/documents/docx-importer.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user