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