import { documentTemplates, documentTemplateCategories, templateVariables, type DocumentTemplate, type InsertDocumentTemplate, type DocumentTemplateCategory, type InsertDocumentTemplateCategory, type TemplateVariable, type InsertTemplateVariable, type DocumentFolderMapping, type DocumentTemplateFolder, type InsertDocumentTemplateFolder, type InsertDocumentFolderMapping, } from "@shared/schema"; import * as storage from "../storage/documents.storage"; const VARIABLE_REGEX = /\{\{\s*([^}]+?)\s*\}\}/g; const VALID_SOURCES = new Set([ 'static', 'task_field', 'task_system', 'task_table_tab', 'related_task_field', 'asset', 'assignee', 'creator', 'organization', 'current_user', 'current_date', ]); export interface EnrichedVariable extends TemplateVariable { isFolderMapping?: boolean; folderMappingId?: number; } export interface TemplateWithVariables extends DocumentTemplate { variables: EnrichedVariable[]; category?: DocumentTemplateCategory | null; } export class DocumentTemplateService { // ── Categories ───────────────────────────────────────────────────────────── async listCategories(organizationId: number): Promise { return storage.listDocumentTemplateCategories(organizationId); } async getCategory(id: number, organizationId: number): Promise { return storage.getDocumentTemplateCategory(id, organizationId); } async createCategory(data: InsertDocumentTemplateCategory): Promise { return storage.createDocumentTemplateCategory(data); } async updateCategory( id: number, organizationId: number, updates: Partial, ): Promise { return storage.updateDocumentTemplateCategory(id, organizationId, updates); } async deleteCategory(id: number, organizationId: number): Promise { return storage.deleteDocumentTemplateCategory(id, organizationId); } // ── Folders ──────────────────────────────────────────────────────────────── async listFolders(organizationId: number): Promise { return storage.listDocumentTemplateFolders(organizationId); } async getFolder(id: number, organizationId: number): Promise { return storage.getDocumentTemplateFolder(id, organizationId); } async createFolder(data: InsertDocumentTemplateFolder): Promise { return storage.createDocumentTemplateFolder(data); } async updateFolder( id: number, organizationId: number, updates: Partial, ): Promise { return storage.updateDocumentTemplateFolder(id, organizationId, updates); } async deleteFolder(id: number, organizationId: number): Promise { const folder = await this.getFolder(id, organizationId); if (!folder) throw new Error('Папка не найдена'); // Переносим дочерние папки на уровень удаляемой (parentId = удаляемой.parentId) const allFolders = await this.listFolders(organizationId); const children = allFolders.filter((f) => f.parentId === id); for (const child of children) { await storage.updateDocumentTemplateFolder(child.id, organizationId, { parentId: folder.parentId }); } // Удаляем маппинги папки await storage.deleteDocumentFolderMappingsByFolder(id); // Удаляем саму папку await storage.deleteDocumentTemplateFolder(id, organizationId); } async reorderFolders( organizationId: number, updates: Array<{ id: number; parentId: number | null; position: number }>, ): Promise { for (const update of updates) { const folder = await this.getFolder(update.id, organizationId); if (!folder) continue; await storage.updateDocumentTemplateFolder(update.id, organizationId, { parentId: update.parentId, position: update.position, }); } } async moveTemplateToFolder( templateId: number, organizationId: number, folderId: number | null, ): Promise { const template = await storage.getDocumentTemplate(templateId, organizationId); if (!template) return undefined; if (folderId !== null) { const folder = await this.getFolder(folderId, organizationId); if (!folder) throw new Error('Папка не найдена'); } return storage.updateDocumentTemplate(templateId, organizationId, { folderId }); } // ── Folder Mappings ──────────────────────────────────────────────────────── async listFolderMappings(folderId: number, organizationId: number): Promise { const folder = await this.getFolder(folderId, organizationId); if (!folder) throw new Error('Папка не найдена'); return storage.listDocumentFolderMappings(folderId); } async createFolderMapping(data: InsertDocumentFolderMapping): Promise { this.validateVariable(data, 0); return storage.createDocumentFolderMapping(data); } async updateFolderMapping( id: number, folderId: number, updates: Partial, ): Promise { if (updates.source !== undefined || updates.mapping !== undefined) { this.validateVariable( { source: updates.source || 'static', mapping: updates.mapping || {} }, 0, ); } return storage.updateDocumentFolderMapping(id, folderId, updates); } async deleteFolderMapping(id: number, folderId: number): Promise { return storage.deleteDocumentFolderMapping(id, folderId); } // ── Templates ────────────────────────────────────────────────────────────── async list(filters: { organizationId: number; status?: string; categoryId?: number; folderId?: number; formId?: number; }): Promise { return storage.listDocumentTemplates(filters); } async getById(id: number, organizationId: number): Promise { const template = await storage.getDocumentTemplate(id, organizationId); if (!template) return undefined; const templateVariables = await storage.listTemplateVariables(id); const category = template.categoryId ? await this.getCategory(template.categoryId, organizationId) : null; const variables = await this.mergeFolderVariables(template, templateVariables); return { ...template, variables, category: category || undefined }; } private async mergeFolderVariables( template: DocumentTemplate, templateVariables: TemplateVariable[], ): Promise { if (!template.folderId) { return templateVariables.map((v) => ({ ...v, isFolderMapping: false })); } const folderMappings = await storage.listDocumentFolderMappings(template.folderId); const merged = new Map(); for (const m of folderMappings) { merged.set(m.code, this.mappingToVariable(m, template.id)); } // Переменные шаблона переопределяют маппинги папки for (const v of templateVariables) { merged.set(v.code, { ...v, isFolderMapping: false }); } return Array.from(merged.values()).sort((a, b) => { const orderDiff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0); if (orderDiff !== 0) return orderDiff; return (a.id ?? 0) - (b.id ?? 0); }); } private mappingToVariable(mapping: DocumentFolderMapping, templateId: number): EnrichedVariable { return { id: -mapping.id, templateId, code: mapping.code, label: mapping.label, description: mapping.description, source: mapping.source, mapping: mapping.mapping, defaultValue: mapping.defaultValue, sortOrder: mapping.sortOrder ?? 0, createdAt: mapping.createdAt, isFolderMapping: true, folderMappingId: mapping.id, }; } async getByIdRaw(id: number, organizationId: number): Promise { return storage.getDocumentTemplate(id, organizationId); } async create(data: InsertDocumentTemplate & { variables?: InsertTemplateVariable[] }): Promise { const template = await storage.createDocumentTemplate(data); if (data.variables && data.variables.length > 0) { for (let i = 0; i < data.variables.length; i++) { const v = data.variables[i]; this.validateVariable(v, i); await storage.createTemplateVariable({ ...v, templateId: template.id }); } } return this.getById(template.id, template.organizationId) as Promise; } async update( id: number, organizationId: number, updates: Partial & { variables?: Array & { id?: number }> }, ): Promise { const template = await storage.updateDocumentTemplate(id, organizationId, updates); if (!template) return undefined; if (updates.variables) { const existing = await storage.listTemplateVariables(id); const incomingIds = updates.variables.filter((v) => v.id && v.id > 0).map((v) => v.id!); const toDelete = existing.filter((e) => !incomingIds.includes(e.id)); for (const v of toDelete) { await storage.deleteTemplateVariable(v.id, id); } for (let i = 0; i < updates.variables.length; i++) { const v = updates.variables[i]; if (v.id && v.id < 0) continue; // маппинги папки редактируются отдельно this.validateVariable(v, i); const variableData: InsertTemplateVariable = { templateId: id, code: v.code || `var_${i}`, label: v.label || v.code || `var_${i}`, description: v.description || null, source: v.source || 'static', mapping: v.mapping || {}, defaultValue: v.defaultValue || null, sortOrder: v.sortOrder ?? i, }; if (v.id && v.id > 0) { await storage.updateTemplateVariable(v.id, id, variableData); } else { await storage.createTemplateVariable(variableData); } } } return this.getById(id, organizationId); } private validateVariable(v: Partial, index: number): void { const source = v.source || 'static'; if (!VALID_SOURCES.has(source)) { throw new Error(`Некорректный источник переменной #${index + 1}: ${source}`); } const mapping = (v.mapping || {}) as Record; if (source === 'related_task_field') { if (!mapping.relationType) { throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать тип связи`); } if (!mapping.targetFieldCode) { throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать целевое поле`); } } if (source === 'task_field' && !mapping.fieldCode) { throw new Error(`Для переменной #${index + 1} с источником «Поле задачи» необходимо указать код поля`); } if (source === 'task_table_tab' && !mapping.tabId) { throw new Error(`Для переменной #${index + 1} с источником «Таблица задачи» необходимо выбрать вкладку`); } } async delete(id: number, organizationId: number): Promise { await storage.deleteTemplateVariablesByTemplate(id); await storage.deleteDocumentTemplate(id, organizationId); } async duplicate(id: number, organizationId: number, newName?: string): Promise { const source = await this.getById(id, organizationId); if (!source) throw new Error('Шаблон не найден'); const { id: _id, createdAt, updatedAt, status, ...rest } = source; const template = await storage.createDocumentTemplate({ ...rest, name: newName || `${source.name} (копия)`, status: 'draft', organizationId, createdBy: source.createdBy, }); const variables: TemplateVariable[] = []; for (const v of source.variables) { if (v.isFolderMapping) continue; const { id: _vid, templateId, createdAt: _vCreatedAt, isFolderMapping, folderMappingId, ...vRest } = v; variables.push( await storage.createTemplateVariable({ ...vRest, templateId: template.id, }), ); } return { ...template, variables }; } // ── Variables ────────────────────────────────────────────────────────────── async listVariables(templateId: number, organizationId: number): Promise { // Убедимся, что шаблон принадлежит организации const template = await storage.getDocumentTemplate(templateId, organizationId); if (!template) throw new Error('Шаблон не найден'); return storage.listTemplateVariables(templateId); } async parseVariablesFromContent(content: string): Promise { const codes = new Set(); let match: RegExpExecArray | null; while ((match = VARIABLE_REGEX.exec(content)) !== null) { codes.add(match[1].trim()); } return Array.from(codes); } async autoCreateVariablesFromContent(templateId: number, organizationId: number): Promise { const template = await storage.getDocumentTemplate(templateId, organizationId); if (!template) throw new Error('Шаблон не найден'); const codes = await this.parseVariablesFromContent(template.content); const existing = await storage.listTemplateVariables(templateId); const existingCodes = new Set(existing.map((v) => v.code)); const created: TemplateVariable[] = []; for (let i = 0; i < codes.length; i++) { const code = codes[i]; if (existingCodes.has(code)) continue; const variable = await storage.createTemplateVariable({ templateId, code, label: code, source: 'static', mapping: {}, defaultValue: null, sortOrder: existing.length + i, }); created.push(variable); } return created; } }