398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
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;
|
||
}
|
||
}
|