fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
516
server/documents/generation.service.ts
Normal file
516
server/documents/generation.service.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3";
|
||||
import { uploadsDir } from "../utils/upload";
|
||||
import { db } from "../db";
|
||||
import { fileUploads } from "@shared/schema";
|
||||
import { DocumentTemplateService } from "./template.service";
|
||||
import { DataResolutionService } from "./data-resolution.service";
|
||||
import { AssetService } from "./asset.service";
|
||||
import { PDFConverterService } from "./pdf-converter.service";
|
||||
import { DOCXConverterService } from "./docx-converter.service";
|
||||
import { convertDocxToPdf } from "./external-pdf.service";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import * as documentStorage from "../storage/documents.storage";
|
||||
import { storage } from "../storage/index";
|
||||
import type { DocumentTemplate, TemplateVariable } from "@shared/schema";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SCRIPT_PATH = path.resolve(process.cwd(), "server/scripts/docx_form_controls.py");
|
||||
|
||||
export type OutputFormat = 'pdf' | 'docx' | 'both';
|
||||
|
||||
export interface GenerateRequest {
|
||||
templateId: number;
|
||||
taskId: number;
|
||||
organizationId: number;
|
||||
userId: number;
|
||||
outputFormat: OutputFormat;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
generationId: number;
|
||||
pdfUrl?: string;
|
||||
docxUrl?: string;
|
||||
unresolvedVariables: string[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
html: string;
|
||||
unresolvedVariables: string[];
|
||||
}
|
||||
|
||||
interface AssetPlacement {
|
||||
assetId: number;
|
||||
position: 'header' | 'footer' | 'top-right' | 'bottom-right' | 'bottom-left' | 'custom';
|
||||
page?: 'first' | 'last' | 'all';
|
||||
customX?: number;
|
||||
customY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export class DocumentGenerationService {
|
||||
private pdfConverter = new PDFConverterService();
|
||||
private docxConverter = new DOCXConverterService();
|
||||
|
||||
constructor(
|
||||
private templateService: DocumentTemplateService,
|
||||
private dataResolution: DataResolutionService,
|
||||
private assetService: AssetService,
|
||||
) {}
|
||||
|
||||
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
||||
const template = await this.templateService.getById(request.templateId, request.organizationId);
|
||||
if (!template) throw new Error('Шаблон не найден');
|
||||
|
||||
if (template.sourceDocxFileKey) {
|
||||
return this.generateFromDocx(template, request);
|
||||
}
|
||||
return this.generateFromHtml(template, request);
|
||||
}
|
||||
|
||||
private async generateFromHtml(template: any, request: GenerateRequest): Promise<GenerateResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const generation = await documentStorage.createDocumentGeneration({
|
||||
organizationId: request.organizationId,
|
||||
templateId: request.templateId,
|
||||
taskId: request.taskId,
|
||||
generatedBy: request.userId,
|
||||
templateSnapshot: template.content,
|
||||
status: 'processing',
|
||||
substitutedData: {},
|
||||
});
|
||||
|
||||
try {
|
||||
const { html, unresolvedVariables } = await this.prepareHtml(
|
||||
template,
|
||||
request.taskId,
|
||||
request.organizationId,
|
||||
request.userId,
|
||||
);
|
||||
|
||||
const fileKeys: { pdf?: string; docx?: string } = {};
|
||||
|
||||
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
||||
const pdfBuffer = await this.pdfConverter.convert(html, {
|
||||
format: template.pageSize || 'A4',
|
||||
landscape: template.pageOrientation === 'landscape',
|
||||
margin: template.margins || { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
||||
printBackground: true,
|
||||
footerTemplate: `
|
||||
<div style="font-size:9pt; text-align:center; width:100%; color:#666; padding:0 40px;">
|
||||
<span class="pageNumber"></span> / <span class="totalPages"></span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
const { key } = await uploadToS3(
|
||||
pdfBuffer,
|
||||
`${template.name || 'document'}-${generation.id}.pdf`,
|
||||
'application/pdf',
|
||||
);
|
||||
fileKeys.pdf = key;
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
||||
}
|
||||
|
||||
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
|
||||
const docxBuffer = await this.docxConverter.convert(html, {
|
||||
title: template.name,
|
||||
margins: template.margins || { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
||||
});
|
||||
const { key } = await uploadToS3(
|
||||
docxBuffer,
|
||||
`${template.name || 'document'}-${generation.id}.docx`,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
fileKeys.docx = key;
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxBuffer.length, request);
|
||||
}
|
||||
|
||||
const { resolved } = await this.dataResolution.resolveAll(
|
||||
template.variables,
|
||||
request.taskId,
|
||||
request.organizationId,
|
||||
request.userId,
|
||||
);
|
||||
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'completed',
|
||||
pdfFileKey: fileKeys.pdf || null,
|
||||
docxFileKey: fileKeys.docx || null,
|
||||
substitutedData: resolved,
|
||||
unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : null,
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
||||
|
||||
return {
|
||||
generationId: generation.id,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
|
||||
unresolvedVariables,
|
||||
status: 'completed',
|
||||
};
|
||||
} catch (error) {
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateFromDocx(template: any, request: GenerateRequest): Promise<GenerateResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const { resolved, unresolved } = await this.dataResolution.resolveAll(
|
||||
template.variables,
|
||||
request.taskId,
|
||||
request.organizationId,
|
||||
request.userId,
|
||||
);
|
||||
|
||||
const generation = await documentStorage.createDocumentGeneration({
|
||||
organizationId: request.organizationId,
|
||||
templateId: request.templateId,
|
||||
taskId: request.taskId,
|
||||
generatedBy: request.userId,
|
||||
templateSnapshot: template.sourceDocxFileKey,
|
||||
status: 'processing',
|
||||
substitutedData: resolved,
|
||||
});
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'docx-generate-'));
|
||||
const sourcePath = path.join(tmpDir, 'source.docx');
|
||||
const filledPath = path.join(tmpDir, 'filled.docx');
|
||||
|
||||
try {
|
||||
await downloadFromS3(template.sourceDocxFileKey, sourcePath);
|
||||
|
||||
// Разделяем скалярные переменные, таблицы и ассеты
|
||||
const scalarValues: Record<string, string> = {};
|
||||
const tableValues: Record<string, { tabName?: string; columns: any[]; rows: any[] }> = {};
|
||||
const assetValues: Record<string, { type: string; path: string; width?: number; height?: number }> = {};
|
||||
for (const [code, value] of Object.entries(resolved)) {
|
||||
const variable = template.variables.find((v: TemplateVariable) => v.code === code);
|
||||
if (variable?.source === 'task_table_tab') {
|
||||
try {
|
||||
tableValues[code] = JSON.parse(value);
|
||||
} catch {
|
||||
scalarValues[code] = value;
|
||||
}
|
||||
} else if (variable?.source === 'asset' && value?.startsWith('__ASSET__:')) {
|
||||
const fileKey = value.replace('__ASSET__:', '');
|
||||
const assetId = (variable.mapping as Record<string, any>)?.assetId;
|
||||
const asset = assetId
|
||||
? await documentStorage.getOrganizationAsset(Number(assetId), request.organizationId)
|
||||
: undefined;
|
||||
const destPath = path.join(tmpDir, `asset-${code}-${Date.now()}${path.extname(fileKey) || '.png'}`);
|
||||
await this.downloadAssetFile(fileKey, destPath);
|
||||
assetValues[code] = {
|
||||
type: 'image',
|
||||
path: destPath,
|
||||
width: asset?.settings?.width,
|
||||
height: asset?.settings?.height,
|
||||
};
|
||||
} else {
|
||||
scalarValues[code] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Подставляем переменные {{var}} через Python-скрипт
|
||||
const valuesArgs = Object.entries(scalarValues).map(([k, v]) => `${k}=${v}`);
|
||||
const pythonArgs = [SCRIPT_PATH, 'replace', sourcePath, filledPath];
|
||||
if (valuesArgs.length > 0) {
|
||||
pythonArgs.push(...valuesArgs);
|
||||
}
|
||||
if (Object.keys(tableValues).length > 0) {
|
||||
const tablesPath = path.join(tmpDir, 'tables.json');
|
||||
await fs.writeFile(tablesPath, JSON.stringify(tableValues), 'utf-8');
|
||||
pythonArgs.push('--tables', tablesPath);
|
||||
}
|
||||
if (Object.keys(assetValues).length > 0) {
|
||||
const assetsPath = path.join(tmpDir, 'assets.json');
|
||||
await fs.writeFile(assetsPath, JSON.stringify(assetValues), 'utf-8');
|
||||
pythonArgs.push('--assets', assetsPath);
|
||||
}
|
||||
if (valuesArgs.length > 0 || Object.keys(tableValues).length > 0 || Object.keys(assetValues).length > 0) {
|
||||
await execFileAsync('python3', pythonArgs);
|
||||
} else {
|
||||
await fs.copyFile(sourcePath, filledPath);
|
||||
}
|
||||
|
||||
const fileKeys: { pdf?: string; docx?: string } = {};
|
||||
|
||||
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
|
||||
const { key } = await uploadToS3FromFile(
|
||||
filledPath,
|
||||
`${template.name || 'document'}-${generation.id}.docx`,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
fileKeys.docx = key;
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, (await fs.stat(filledPath)).size, request);
|
||||
}
|
||||
|
||||
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
||||
const pdfBuffer = await convertDocxToPdf(await fs.readFile(filledPath));
|
||||
if (!pdfBuffer) {
|
||||
throw new Error('Сервис генерации PDF временно недоступен. Попробуйте позже.');
|
||||
}
|
||||
const { key } = await uploadToS3(
|
||||
pdfBuffer,
|
||||
`${template.name || 'document'}-${generation.id}.pdf`,
|
||||
'application/pdf',
|
||||
);
|
||||
fileKeys.pdf = key;
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
||||
}
|
||||
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'completed',
|
||||
pdfFileKey: fileKeys.pdf || null,
|
||||
docxFileKey: fileKeys.docx || null,
|
||||
substitutedData: resolved,
|
||||
unresolvedVariables: unresolved.length > 0 ? unresolved : null,
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
return {
|
||||
generationId: generation.id,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
|
||||
unresolvedVariables: unresolved,
|
||||
status: 'completed',
|
||||
};
|
||||
} catch (error) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async preview(templateId: number, taskId: number, organizationId: number, userId: number): Promise<PreviewResult> {
|
||||
const template = await this.templateService.getById(templateId, organizationId);
|
||||
if (!template) throw new Error('Шаблон не найден');
|
||||
return this.prepareHtml(template, taskId, organizationId, userId);
|
||||
}
|
||||
|
||||
private async downloadAssetFile(fileKey: string, destPath: string): Promise<void> {
|
||||
if (isS3Enabled) {
|
||||
await downloadFromS3(fileKey, destPath);
|
||||
} else {
|
||||
const src = path.join(uploadsDir, fileKey);
|
||||
await fs.copyFile(src, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async attachToTaskChat(
|
||||
request: GenerateRequest,
|
||||
templateName: string,
|
||||
fileKeys: { pdf?: string; docx?: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const task = await storage.getTask(request.taskId, request.organizationId);
|
||||
if (!task) return;
|
||||
|
||||
const attachments: any[] = [];
|
||||
if (fileKeys.pdf) {
|
||||
attachments.push({
|
||||
url: `/api/files/${fileKeys.pdf}`,
|
||||
name: `${templateName}.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
size: 0,
|
||||
});
|
||||
}
|
||||
if (fileKeys.docx) {
|
||||
attachments.push({
|
||||
url: `/api/files/${fileKeys.docx}`,
|
||||
name: `${templateName}.docx`,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
await storage.createTaskMessage({
|
||||
taskId: request.taskId,
|
||||
formId: task.formId,
|
||||
authorId: request.userId,
|
||||
message: `Сгенерирован документ по шаблону «${templateName}»`,
|
||||
messageType: 'system',
|
||||
attachments,
|
||||
}, request.organizationId);
|
||||
} catch (err) {
|
||||
console.warn('[Documents] Failed to attach generation to task chat:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async trackFile(
|
||||
key: string,
|
||||
originalName: string,
|
||||
sizeBytes: number,
|
||||
request: GenerateRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.insert(fileUploads).values({
|
||||
organizationId: request.organizationId,
|
||||
uploadedBy: request.userId,
|
||||
fileKey: key,
|
||||
originalName,
|
||||
sizeBytes,
|
||||
taskId: request.taskId,
|
||||
fieldId: null,
|
||||
}).onConflictDoNothing();
|
||||
} catch (err) {
|
||||
console.warn('[Documents] Failed to track generated file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareHtml(
|
||||
template: DocumentTemplate & { variables: TemplateVariable[] },
|
||||
taskId: number,
|
||||
organizationId: number,
|
||||
userId: number,
|
||||
): Promise<{ html: string; unresolvedVariables: string[] }> {
|
||||
const { resolved, unresolved } = await this.dataResolution.resolveAll(
|
||||
template.variables,
|
||||
taskId,
|
||||
organizationId,
|
||||
userId,
|
||||
);
|
||||
|
||||
let html = this.substituteVariables(template.content, resolved);
|
||||
html = await this.injectAssets(html, template.assetPlacements as AssetPlacement[] | null, organizationId);
|
||||
html = await this.inlineImagesToBase64(html);
|
||||
html = this.wrapHtml(html, template);
|
||||
|
||||
return { html, unresolvedVariables: unresolved };
|
||||
}
|
||||
|
||||
private substituteVariables(html: string, values: Record<string, string>): string {
|
||||
let result = html;
|
||||
|
||||
// TipTap variables: <span data-variable-name="X">...</span>
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const regex = new RegExp(`(<span[^>]*data-variable-name="${name}"[^>]*>)(.*?)(</span>)`, 'g');
|
||||
result = result.replace(regex, `$1${this.escapeHtml(value)}$3`);
|
||||
}
|
||||
|
||||
// Fallback direct {{X}}
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
result = result.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), this.escapeHtml(value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private async injectAssets(
|
||||
html: string,
|
||||
placements: AssetPlacement[] | null,
|
||||
organizationId: number,
|
||||
): Promise<string> {
|
||||
if (!placements || placements.length === 0) return html;
|
||||
|
||||
const assets = await this.assetService.getAssetsForGeneration(placements, organizationId);
|
||||
if (assets.length === 0) return html;
|
||||
|
||||
let injected = '';
|
||||
for (const asset of assets) {
|
||||
const placement = placements.find((p) => p.assetId === asset.assetId);
|
||||
if (!placement) continue;
|
||||
|
||||
const positionStyles: Record<string, string> = {
|
||||
header: 'position: absolute; top: 20px; right: 30px;',
|
||||
footer: 'position: absolute; bottom: 20px; right: 30px;',
|
||||
'top-right': 'position: absolute; top: 50px; right: 50px;',
|
||||
'bottom-right': 'position: absolute; bottom: 50px; right: 50px;',
|
||||
'bottom-left': 'position: absolute; bottom: 50px; left: 50px;',
|
||||
custom: `position: absolute; left: ${placement.customX || 0}px; top: ${placement.customY || 0}px;`,
|
||||
};
|
||||
|
||||
const width = asset.width || placement.width || 150;
|
||||
const height = asset.height || placement.height || 150;
|
||||
const opacity = asset.opacity || placement.opacity || 0.85;
|
||||
|
||||
injected += `
|
||||
<div style="${positionStyles[placement.position] || ''} width: ${width}px; height: ${height}px; z-index: 100;">
|
||||
<img src="${asset.base64}" style="width: 100%; height: 100%; object-fit: contain; opacity: ${opacity};" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (injected) {
|
||||
return html.replace('</body>', `${injected}</body>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
private async inlineImagesToBase64(html: string): Promise<string> {
|
||||
const imgRegex = /<img[^>]+src=["']\/api\/files\/([^"']+)["'][^>]*>/g;
|
||||
const matches = Array.from(html.matchAll(imgRegex));
|
||||
const keys = [...new Set(matches.map((m) => m[1]))];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const dataUri = await this.assetService.loadAsBase64(key);
|
||||
if (!dataUri) continue;
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
html = html.replace(
|
||||
new RegExp(`/api/files/${escapedKey}`, 'g'),
|
||||
dataUri,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`[Generation] Failed to inline image ${key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private wrapHtml(html: string, template: DocumentTemplate): string {
|
||||
if (html.trim().toLowerCase().startsWith('<!doctype') || html.includes('<html')) {
|
||||
return html;
|
||||
}
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${this.escapeHtml(template.name || 'Document')}</title>
|
||||
<style>
|
||||
body { font-family: 'Noto Sans', 'DejaVu Sans', sans-serif; margin: 0; padding: 0; }
|
||||
img { max-width: 100%; max-height: 80vh; height: auto; width: auto; object-fit: contain; }
|
||||
@page { size: ${template.pageSize || 'A4'} ${template.pageOrientation === 'landscape' ? 'landscape' : 'portrait'}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${html}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user