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

@@ -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,