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 } 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 { 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 { 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: `
/
`, }); 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 { 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 = {}; const tableValues: Record = {}; const assetValues: Record = {}; 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)?.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 { 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 { 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 { 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 { 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), )) .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 { 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 { let result = html; // TipTap variables: ... for (const [name, value] of Object.entries(values)) { const regex = new RegExp(`(]*data-variable-name="${name}"[^>]*>)(.*?)()`, '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, '''); } private async injectAssets( html: string, placements: AssetPlacement[] | null, organizationId: number, ): Promise { 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 = { 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 += `
`; } if (injected) { return html.replace('', `${injected}`); } return html; } private async inlineImagesToBase64(html: string): Promise { const imgRegex = /]+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(' ${this.escapeHtml(template.name || 'Document')} ${html} `; } }