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

398 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<DocumentTemplateCategory[]> {
return storage.listDocumentTemplateCategories(organizationId);
}
async getCategory(id: number, organizationId: number): Promise<DocumentTemplateCategory | undefined> {
return storage.getDocumentTemplateCategory(id, organizationId);
}
async createCategory(data: InsertDocumentTemplateCategory): Promise<DocumentTemplateCategory> {
return storage.createDocumentTemplateCategory(data);
}
async updateCategory(
id: number,
organizationId: number,
updates: Partial<DocumentTemplateCategory>,
): Promise<DocumentTemplateCategory | undefined> {
return storage.updateDocumentTemplateCategory(id, organizationId, updates);
}
async deleteCategory(id: number, organizationId: number): Promise<void> {
return storage.deleteDocumentTemplateCategory(id, organizationId);
}
// ── Folders ────────────────────────────────────────────────────────────────
async listFolders(organizationId: number): Promise<DocumentTemplateFolder[]> {
return storage.listDocumentTemplateFolders(organizationId);
}
async getFolder(id: number, organizationId: number): Promise<DocumentTemplateFolder | undefined> {
return storage.getDocumentTemplateFolder(id, organizationId);
}
async createFolder(data: InsertDocumentTemplateFolder): Promise<DocumentTemplateFolder> {
return storage.createDocumentTemplateFolder(data);
}
async updateFolder(
id: number,
organizationId: number,
updates: Partial<DocumentTemplateFolder>,
): Promise<DocumentTemplateFolder | undefined> {
return storage.updateDocumentTemplateFolder(id, organizationId, updates);
}
async deleteFolder(id: number, organizationId: number): Promise<void> {
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<void> {
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<DocumentTemplate | undefined> {
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<DocumentFolderMapping[]> {
const folder = await this.getFolder(folderId, organizationId);
if (!folder) throw new Error('Папка не найдена');
return storage.listDocumentFolderMappings(folderId);
}
async createFolderMapping(data: InsertDocumentFolderMapping): Promise<DocumentFolderMapping> {
this.validateVariable(data, 0);
return storage.createDocumentFolderMapping(data);
}
async updateFolderMapping(
id: number,
folderId: number,
updates: Partial<InsertDocumentFolderMapping>,
): Promise<DocumentFolderMapping | undefined> {
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<void> {
return storage.deleteDocumentFolderMapping(id, folderId);
}
// ── Templates ──────────────────────────────────────────────────────────────
async list(filters: {
organizationId: number;
status?: string;
categoryId?: number;
folderId?: number;
formId?: number;
}): Promise<DocumentTemplate[]> {
return storage.listDocumentTemplates(filters);
}
async getById(id: number, organizationId: number): Promise<TemplateWithVariables | undefined> {
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<EnrichedVariable[]> {
if (!template.folderId) {
return templateVariables.map((v) => ({ ...v, isFolderMapping: false }));
}
const folderMappings = await storage.listDocumentFolderMappings(template.folderId);
const merged = new Map<string, EnrichedVariable>();
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<DocumentTemplate | undefined> {
return storage.getDocumentTemplate(id, organizationId);
}
async create(data: InsertDocumentTemplate & { variables?: InsertTemplateVariable[] }): Promise<TemplateWithVariables> {
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<TemplateWithVariables>;
}
async update(
id: number,
organizationId: number,
updates: Partial<InsertDocumentTemplate> & { variables?: Array<Partial<TemplateVariable> & { id?: number }> },
): Promise<TemplateWithVariables | undefined> {
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<TemplateVariable>, 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<string, any>;
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<void> {
await storage.deleteTemplateVariablesByTemplate(id);
await storage.deleteDocumentTemplate(id, organizationId);
}
async duplicate(id: number, organizationId: number, newName?: string): Promise<TemplateWithVariables> {
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<TemplateVariable[]> {
// Убедимся, что шаблон принадлежит организации
const template = await storage.getDocumentTemplate(templateId, organizationId);
if (!template) throw new Error('Шаблон не найден');
return storage.listTemplateVariables(templateId);
}
async parseVariablesFromContent(content: string): Promise<string[]> {
const codes = new Set<string>();
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<TemplateVariable[]> {
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;
}
}