Files
iistwin/server/documents/generation.service.ts

638 lines
23 KiB
TypeScript

import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3";
import { uploadsDir } from "../utils/upload";
import { db } from "../db";
import { fileUploads, formFields, forms, tasks, taskFieldValues } from "@shared/schema";
import { eq, and, isNull } from "drizzle-orm";
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;
}
interface FileKeyInfo {
key: string;
size: 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?: FileKeyInfo; docx?: FileKeyInfo } = {};
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, size: pdfBuffer.length };
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, size: docxBuffer.length };
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?.key || null,
docxFileKey: fileKeys.docx?.key || null,
substitutedData: resolved,
unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : null,
generationDurationMs: Date.now() - startTime,
});
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
await this.saveToTargetField(template, request, fileKeys);
return {
generationId: generation.id,
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : 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?: FileKeyInfo; docx?: FileKeyInfo } = {};
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
const { key } = await uploadToS3FromFile(
filledPath,
`${template.name || 'document'}-${generation.id}.docx`,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
const docxSize = (await fs.stat(filledPath)).size;
fileKeys.docx = { key, size: docxSize };
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxSize, 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, size: pdfBuffer.length };
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?.key || null,
docxFileKey: fileKeys.docx?.key || null,
substitutedData: resolved,
unresolvedVariables: unresolved.length > 0 ? unresolved : null,
generationDurationMs: Date.now() - startTime,
});
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
await this.saveToTargetField(template, request, fileKeys);
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
return {
generationId: generation.id,
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : 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?: FileKeyInfo; docx?: FileKeyInfo },
): 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.key}`,
name: `${templateName}.pdf`,
mimeType: 'application/pdf',
size: fileKeys.pdf.size,
});
}
if (fileKeys.docx) {
attachments.push({
url: `/api/files/${fileKeys.docx.key}`,
name: `${templateName}.docx`,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: fileKeys.docx.size,
});
}
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 saveToTargetField(
template: any,
request: GenerateRequest,
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
): Promise<void> {
const targetFormId = template.targetFormId as number | undefined;
const targetFieldCode = template.targetFieldCode as string | undefined;
const targetTaskSource = (template.targetTaskSource as string | undefined) || 'current';
const targetSourceFieldCode = template.targetSourceFieldCode as string | undefined;
if (!targetFormId || !targetFieldCode) return;
try {
const currentTask = await storage.getTask(request.taskId, request.organizationId);
if (!currentTask) return;
let targetTaskId: number | null = null;
if (targetTaskSource === 'current') {
targetTaskId = request.taskId;
} else if (targetTaskSource === 'related_task_field') {
if (!targetSourceFieldCode) return;
const sourceFieldValue = await db
.select({ value: taskFieldValues.value })
.from(taskFieldValues)
.innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId))
.where(and(
eq(taskFieldValues.taskId, request.taskId),
eq(formFields.code, targetSourceFieldCode),
))
.limit(1);
const rawValue = sourceFieldValue[0]?.value;
if (rawValue === null || rawValue === undefined || rawValue === '') return;
if (typeof rawValue === 'number') {
targetTaskId = rawValue;
} else if (typeof rawValue === 'string') {
const parsed = Number(rawValue);
targetTaskId = Number.isFinite(parsed) ? parsed : null;
} else {
return;
}
}
if (!targetTaskId) return;
const targetTask = await storage.getTask(targetTaskId, request.organizationId);
if (!targetTask) return;
const [targetField] = await db
.select()
.from(formFields)
.where(and(
eq(formFields.formId, targetFormId),
eq(formFields.code, targetFieldCode),
isNull(formFields.deletedAt),
))
.limit(1);
if (!targetField || targetField.type !== 'file') {
console.warn(`[Documents] Target field ${targetFieldCode} is not a file field or not found`);
return;
}
const existingValues = await storage.getTaskFieldValues(targetTaskId, request.organizationId);
const existingValue = existingValues.find((v) => v.fieldId === targetField.id);
const existingFiles: Array<{ url: string; name: string; size: number }> = Array.isArray(existingValue?.value)
? (existingValue.value as any[])
: [];
const newFiles: Array<{ url: string; name: string; size: number }> = [];
if (fileKeys.pdf) {
newFiles.push({
url: `/api/files/${fileKeys.pdf.key}`,
name: `${template.name || 'document'}.pdf`,
size: fileKeys.pdf.size,
});
}
if (fileKeys.docx) {
newFiles.push({
url: `/api/files/${fileKeys.docx.key}`,
name: `${template.name || 'document'}.docx`,
size: fileKeys.docx.size,
});
}
const mergedFiles = [...existingFiles, ...newFiles];
if (existingValue) {
await storage.updateTaskFieldValue(targetTaskId, targetField.id, request.organizationId, {
value: mergedFiles,
});
} else {
await storage.createTaskFieldValue({
taskId: targetTaskId,
fieldId: targetField.id,
formId: targetFormId,
value: mergedFiles,
});
}
// Обновляем fileUploads: привязываем сгенерированные файлы к целевой задаче/полю
for (const file of newFiles) {
const key = file.url.replace('/api/files/', '');
await db.update(fileUploads)
.set({ taskId: targetTaskId, fieldId: targetField.id })
.where(eq(fileUploads.fileKey, key));
}
} catch (err) {
console.warn('[Documents] Failed to save generated document to target field:', 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>`;
}
}