feat(documents): папки шаблонов, маппинги и поле document_template; fix(batch): атомарное создание задач, idempotency и массовое удаление в реестре

This commit is contained in:
2026-07-08 13:33:14 +03:00
parent fc8e85832c
commit 6826d82850
34 changed files with 3770 additions and 467 deletions

View File

@@ -1,6 +1,6 @@
import { db } from "../db";
import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows } from "@shared/schema";
import { eq, and, asc } from "drizzle-orm";
import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows, taskRelations } from "@shared/schema";
import { eq, and, asc, sql } from "drizzle-orm";
import type { TemplateVariable } from "@shared/schema";
import { TransformService, type TransformConfig, type TransformType } from "./transform.service";
import * as storage from "../storage";
@@ -110,6 +110,10 @@ export class DataResolutionService {
tabId?: number;
transform?: TransformConfig | TransformType | string;
locale?: string;
relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity';
sourceFieldCode?: string;
targetFormId?: number;
targetFieldCode?: string;
};
let rawValue: string | null = null;
@@ -149,8 +153,12 @@ export class DataResolutionService {
break;
}
case 'related_task_field': {
// TODO: реализовать при необходимости
rawValue = null;
rawValue = await this.resolveRelatedTaskField(
variable,
mapping,
context,
organizationId,
);
break;
}
case 'task_table_tab': {
@@ -327,4 +335,108 @@ export class DataResolutionService {
if (!asset) return null;
return `__ASSET__:${asset.fileKey}`;
}
private async resolveRelatedTaskField(
variable: TemplateVariable,
mapping: Record<string, any>,
context: ResolutionContext,
organizationId: number,
): Promise<string | null> {
const relationType = mapping.relationType || 'task_field';
const targetFormId = mapping.targetFormId ? Number(mapping.targetFormId) : undefined;
const targetFieldCode = mapping.targetFieldCode as string | undefined;
if (!targetFieldCode) return null;
const relatedTaskId = await this.getRelatedTaskId(
relationType,
mapping.sourceFieldCode as string | undefined,
context,
organizationId,
targetFormId,
);
if (!relatedTaskId) return null;
const relatedFieldValues = await this.getRelatedTaskFieldValues(relatedTaskId);
const fv = relatedFieldValues.find((f) => f.code === targetFieldCode);
if (!fv) return null;
return this.extractFieldDisplayValue(fv.value, fv.type);
}
private async getRelatedTaskId(
relationType: string,
sourceFieldCode: string | undefined,
context: ResolutionContext,
organizationId: number,
targetFormId?: number,
): Promise<number | null> {
switch (relationType) {
case 'task_field': {
if (!sourceFieldCode) return null;
const fv = context.fieldValues.find((f) => f.code === sourceFieldCode);
if (!fv) return null;
const value = fv.value;
if (value === null || value === undefined || value === '') return null;
// task-поле хранит ID связанной задачи числом
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
case 'contract_legal_entity': {
// Ищем поле типа contract-number и берём legalEntityTaskId
const contractFv = context.fieldValues.find((f) => f.type === 'contract-number');
if (!contractFv || !contractFv.value) return null;
const obj = typeof contractFv.value === 'string' ? JSON.parse(contractFv.value) : contractFv.value;
const legalEntityTaskId = obj?.legalEntityTaskId;
if (legalEntityTaskId && typeof legalEntityTaskId === 'number') return legalEntityTaskId;
return null;
}
case 'parent':
case 'child': {
const relations = await db
.select()
.from(taskRelations)
.where(and(
eq(taskRelations.organizationId, organizationId),
relationType === 'parent'
? eq(taskRelations.childTaskId, context.task.id)
: eq(taskRelations.parentTaskId, context.task.id),
));
if (relations.length === 0) return null;
const candidateIds = relations.map((r) =>
relationType === 'parent' ? r.parentTaskId : r.childTaskId,
);
if (!targetFormId) return candidateIds[0] || null;
for (const id of candidateIds) {
const [relatedTask] = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
if (relatedTask && relatedTask.formId === targetFormId) return id;
}
return null;
}
default:
return null;
}
}
private async getRelatedTaskFieldValues(taskId: number): Promise<Array<{ code: string; value: unknown; type: string; name: string }>> {
return db
.select({
code: formFields.code,
value: taskFieldValues.value,
type: formFields.type,
name: formFields.name,
})
.from(taskFieldValues)
.innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId))
.where(eq(taskFieldValues.taskId, taskId));
}
}

View File

@@ -18,7 +18,11 @@ export class DOCXConverterService {
lang: options.lang || 'ru-RU',
};
const result = await HTMLtoDOCX(html, null, opts);
const convertFn = typeof HTMLtoDOCX === 'function' ? HTMLtoDOCX : (HTMLtoDOCX as any).default;
if (typeof convertFn !== 'function') {
throw new Error('HTMLtoDOCX converter is not available');
}
const result = await convertFn(html, null, opts);
if (result instanceof ArrayBuffer) {
return Buffer.from(result);
}

View File

@@ -1,7 +1,8 @@
import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3";
import { uploadsDir } from "../utils/upload";
import { db } from "../db";
import { fileUploads } from "@shared/schema";
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";
@@ -55,6 +56,11 @@ interface AssetPlacement {
opacity?: number;
}
interface FileKeyInfo {
key: string;
size: number;
}
export class DocumentGenerationService {
private pdfConverter = new PDFConverterService();
private docxConverter = new DOCXConverterService();
@@ -96,7 +102,7 @@ export class DocumentGenerationService {
request.userId,
);
const fileKeys: { pdf?: string; docx?: string } = {};
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
const pdfBuffer = await this.pdfConverter.convert(html, {
@@ -115,7 +121,7 @@ export class DocumentGenerationService {
`${template.name || 'document'}-${generation.id}.pdf`,
'application/pdf',
);
fileKeys.pdf = key;
fileKeys.pdf = { key, size: pdfBuffer.length };
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
}
@@ -129,7 +135,7 @@ export class DocumentGenerationService {
`${template.name || 'document'}-${generation.id}.docx`,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
fileKeys.docx = key;
fileKeys.docx = { key, size: docxBuffer.length };
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxBuffer.length, request);
}
@@ -142,19 +148,20 @@ export class DocumentGenerationService {
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
status: 'completed',
pdfFileKey: fileKeys.pdf || null,
docxFileKey: fileKeys.docx || null,
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}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
unresolvedVariables,
status: 'completed',
};
@@ -248,7 +255,7 @@ export class DocumentGenerationService {
await fs.copyFile(sourcePath, filledPath);
}
const fileKeys: { pdf?: string; docx?: string } = {};
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
const { key } = await uploadToS3FromFile(
@@ -256,8 +263,9 @@ export class DocumentGenerationService {
`${template.name || 'document'}-${generation.id}.docx`,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
fileKeys.docx = key;
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, (await fs.stat(filledPath)).size, request);
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') {
@@ -270,27 +278,28 @@ export class DocumentGenerationService {
`${template.name || 'document'}-${generation.id}.pdf`,
'application/pdf',
);
fileKeys.pdf = key;
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 || null,
docxFileKey: fileKeys.docx || null,
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}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
unresolvedVariables: unresolved,
status: 'completed',
};
@@ -323,7 +332,7 @@ export class DocumentGenerationService {
private async attachToTaskChat(
request: GenerateRequest,
templateName: string,
fileKeys: { pdf?: string; docx?: string },
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
): Promise<void> {
try {
const task = await storage.getTask(request.taskId, request.organizationId);
@@ -332,18 +341,18 @@ export class DocumentGenerationService {
const attachments: any[] = [];
if (fileKeys.pdf) {
attachments.push({
url: `/api/files/${fileKeys.pdf}`,
url: `/api/files/${fileKeys.pdf.key}`,
name: `${templateName}.pdf`,
mimeType: 'application/pdf',
size: 0,
size: fileKeys.pdf.size,
});
}
if (fileKeys.docx) {
attachments.push({
url: `/api/files/${fileKeys.docx}`,
url: `/api/files/${fileKeys.docx.key}`,
name: `${templateName}.docx`,
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
size: 0,
size: fileKeys.docx.size,
});
}
@@ -362,6 +371,117 @@ export class DocumentGenerationService {
}
}
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),
))
.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,

View File

@@ -9,6 +9,7 @@ import { PackageService } from "./package.service";
import { SignatureService } from "./signature.service";
import { DocxImporterService } from "./docx-importer.service";
import * as storage from "../storage/documents.storage";
import { storage as appStorage } from "../storage/index";
export async function registerDocumentRoutes(app: Express) {
const templateService = new DocumentTemplateService();
@@ -49,6 +50,79 @@ export async function registerDocumentRoutes(app: Express) {
res.json({ success: true });
});
// ── Template Folders ───────────────────────────────────────────────────────
app.get("/api/documents/folders", authenticateToken, async (req: AuthenticatedRequest, res) => {
const folders = await templateService.listFolders(req.organizationId!);
res.json({ success: true, folders });
});
app.post("/api/documents/folders", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
const folder = await templateService.createFolder({
...req.body,
organizationId: req.organizationId!,
});
res.status(201).json({ success: true, folder });
});
app.put("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
const folder = await templateService.updateFolder(
Number(req.params.id),
req.organizationId!,
req.body,
);
if (!folder) return res.status(404).json({ error: "Папка не найдена" });
res.json({ success: true, folder });
});
app.delete("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
try {
await templateService.deleteFolder(Number(req.params.id), req.organizationId!);
res.json({ success: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Ошибка удаления папки";
res.status(400).json({ error: message });
}
});
app.patch("/api/documents/folders/reorder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
try {
await templateService.reorderFolders(req.organizationId!, req.body.updates || []);
res.json({ success: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Ошибка изменения порядка папок";
res.status(400).json({ error: message });
}
});
app.get("/api/documents/folders/:id/mappings", authenticateToken, async (req: AuthenticatedRequest, res) => {
const mappings = await templateService.listFolderMappings(Number(req.params.id), req.organizationId!);
res.json({ success: true, mappings });
});
app.post("/api/documents/folders/:id/mappings", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
const mapping = await templateService.createFolderMapping({
...req.body,
folderId: Number(req.params.id),
});
res.status(201).json({ success: true, mapping });
});
app.put("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
const mapping = await templateService.updateFolderMapping(
Number(req.params.mappingId),
Number(req.params.id),
req.body,
);
if (!mapping) return res.status(404).json({ error: "Маппинг не найден" });
res.json({ success: true, mapping });
});
app.delete("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
await templateService.deleteFolderMapping(Number(req.params.mappingId), Number(req.params.id));
res.json({ success: true });
});
// ── Templates ──────────────────────────────────────────────────────────────
app.get("/api/documents/templates", authenticateToken, async (req: AuthenticatedRequest, res) => {
@@ -56,6 +130,7 @@ export async function registerDocumentRoutes(app: Express) {
organizationId: req.organizationId!,
status: req.query.status as string | undefined,
categoryId: req.query.categoryId ? Number(req.query.categoryId) : undefined,
folderId: req.query.folderId ? Number(req.query.folderId) : undefined,
formId: req.query.formId ? Number(req.query.formId) : undefined,
});
res.json({ success: true, templates });
@@ -132,6 +207,16 @@ export async function registerDocumentRoutes(app: Express) {
res.json({ success: true, template });
});
app.patch("/api/documents/templates/:id/folder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
const template = await templateService.moveTemplateToFolder(
Number(req.params.id),
req.organizationId!,
req.body.folderId === null ? null : Number(req.body.folderId),
);
if (!template) return res.status(404).json({ error: "Шаблон не найден" });
res.json({ success: true, template });
});
app.delete("/api/documents/templates/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
try {
await templateService.delete(Number(req.params.id), req.organizationId!);
@@ -236,6 +321,40 @@ export async function registerDocumentRoutes(app: Express) {
}
});
app.get("/api/documents/generate-link", authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const templateId = Number(req.query.templateId);
const taskId = Number(req.query.taskId);
const format = (req.query.format as string) || 'both';
const returnTo = (req.query.returnTo as string) || '';
if (!templateId || !taskId) {
return res.status(400).json({ error: "templateId и taskId обязательны" });
}
if (!['pdf', 'docx', 'both'].includes(format)) {
return res.status(400).json({ error: "Недопустимый формат" });
}
await generationService.generate({
templateId,
taskId,
organizationId: req.organizationId!,
userId: req.user!.id,
outputFormat: format as any,
});
let redirectUrl = returnTo;
if (!redirectUrl) {
const task = await appStorage.getTask(taskId, req.organizationId!);
redirectUrl = task ? `/forms/${task.formId}/tasks/${taskId}` : '/';
}
res.redirect(redirectUrl);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Ошибка генерации документа";
res.status(400).json({ error: message });
}
});
app.post("/api/documents/generations/preview", authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const preview = await generationService.preview(

View File

@@ -8,13 +8,36 @@ import {
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: TemplateVariable[];
variables: EnrichedVariable[];
category?: DocumentTemplateCategory | null;
}
@@ -45,12 +68,112 @@ export class DocumentTemplateService {
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);
@@ -59,26 +182,72 @@ export class DocumentTemplateService {
async getById(id: number, organizationId: number): Promise<TemplateWithVariables | undefined> {
const template = await storage.getDocumentTemplate(id, organizationId);
if (!template) return undefined;
const variables = await storage.listTemplateVariables(id);
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);
const variables: TemplateVariable[] = [];
if (data.variables && data.variables.length > 0) {
for (const v of data.variables) {
variables.push(await storage.createTemplateVariable({ ...v, templateId: template.id }));
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 { ...template, variables };
return this.getById(template.id, template.organizationId) as Promise<TemplateWithVariables>;
}
async update(
@@ -99,6 +268,8 @@ export class DocumentTemplateService {
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}`,
@@ -120,6 +291,32 @@ export class DocumentTemplateService {
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);
@@ -140,7 +337,8 @@ export class DocumentTemplateService {
const variables: TemplateVariable[] = [];
for (const v of source.variables) {
const { id: _vid, templateId, createdAt: _vCreatedAt, ...vRest } = v;
if (v.isFolderMapping) continue;
const { id: _vid, templateId, createdAt: _vCreatedAt, isFolderMapping, folderMappingId, ...vRest } = v;
variables.push(
await storage.createTemplateVariable({
...vRest,