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

@@ -425,7 +425,7 @@ export const formFields = pgTable("form_fields", {
tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "set null" }), // вкладка для поля
name: varchar("name", { length: 200 }).notNull(),
code: varchar("code", { length: 100 }).notNull(),
type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task
type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task, document_template
isRequired: boolean("is_required").default(false),
placeholder: text("placeholder"),
defaultValue: text("default_value"),
@@ -1135,7 +1135,7 @@ export const createFormSchema = z.object({
fields: z.array(z.object({
name: z.string().min(1, "Название поля обязательно").max(200),
code: z.string().min(1, "Код поля обязателен").max(100).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Код поля может содержать только буквы, цифры и подчеркивания"),
type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number']),
type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number', 'document_template']),
isRequired: z.boolean().default(false),
placeholder: z.string().optional(),
defaultValue: z.string().optional(),
@@ -3111,10 +3111,65 @@ export const documentTemplateCategories = pgTable("document_template_categories"
export type DocumentTemplateCategory = typeof documentTemplateCategories.$inferSelect;
export type InsertDocumentTemplateCategory = typeof documentTemplateCategories.$inferInsert;
export const documentTemplateFolders = pgTable("document_template_folders", {
id: serial("id").primaryKey(),
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
parentId: integer("parent_id"),
name: varchar("name", { length: 100 }).notNull(),
position: integer("position").notNull().default(0),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => ({
orgParentIdx: index("document_template_folders_org_parent_idx").on(table.organizationId, table.parentId),
positionIdx: index("document_template_folders_position_idx").on(table.position),
}));
export type DocumentTemplateFolder = typeof documentTemplateFolders.$inferSelect;
export type InsertDocumentTemplateFolder = typeof documentTemplateFolders.$inferInsert;
export const documentFolderMappings = pgTable("document_folder_mappings", {
id: serial("id").primaryKey(),
folderId: integer("folder_id").notNull().references(() => documentTemplateFolders.id, { onDelete: "cascade" }),
code: varchar("code", { length: 100 }).notNull(),
label: varchar("label", { length: 200 }),
description: text("description"),
source: varchar("source", { length: 30 }).notNull(),
mapping: jsonb("mapping").$type<{
fieldCode?: string;
formId?: number;
systemKey?: string;
staticValue?: string;
userId?: number;
relatedTaskId?: number;
tabId?: number;
assetType?: string;
assetId?: number;
selectionMode?: string;
taskFieldCode?: string;
// related_task_field support
relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity';
sourceFormId?: number;
sourceFieldCode?: string;
targetFormId?: number;
targetFieldCode?: string;
transform?: { type: string; params?: Record<string, any> } | string;
locale?: string;
}>().default({}),
defaultValue: text("default_value"),
sortOrder: integer("sort_order").default(0),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({
uniqueCodePerFolder: unique("document_folder_mappings_code_unique").on(table.folderId, table.code),
}));
export type DocumentFolderMapping = typeof documentFolderMappings.$inferSelect;
export type InsertDocumentFolderMapping = typeof documentFolderMappings.$inferInsert;
export const documentTemplates = pgTable("document_templates", {
id: serial("id").primaryKey(),
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
categoryId: integer("category_id").references(() => documentTemplateCategories.id, { onDelete: "set null" }),
folderId: integer("folder_id").references(() => documentTemplateFolders.id, { onDelete: "set null" }),
formId: integer("form_id").references(() => forms.id, { onDelete: "set null" }),
name: varchar("name", { length: 200 }).notNull(),
description: text("description"),
@@ -3133,6 +3188,11 @@ export const documentTemplates = pgTable("document_templates", {
opacity?: number;
}>>().default([]),
sourceDocxFileKey: varchar("source_docx_file_key", { length: 500 }),
// Target file field where generated document should be saved
targetFormId: integer("target_form_id").references(() => forms.id, { onDelete: "set null" }),
targetFieldCode: varchar("target_field_code", { length: 100 }),
targetTaskSource: varchar("target_task_source", { length: 20 }).default("current"),
targetSourceFieldCode: varchar("target_source_field_code", { length: 100 }),
status: varchar("status", { length: 20 }).notNull().default("draft"),
createdBy: integer("created_by").notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow(),
@@ -3140,6 +3200,7 @@ export const documentTemplates = pgTable("document_templates", {
}, (table) => ({
orgStatusIdx: index("document_templates_org_status_idx").on(table.organizationId, table.status),
orgCategoryIdx: index("document_templates_org_category_idx").on(table.organizationId, table.categoryId),
orgFolderIdx: index("document_templates_org_folder_idx").on(table.organizationId, table.folderId),
}));
export type DocumentTemplate = typeof documentTemplates.$inferSelect;
@@ -3164,6 +3225,12 @@ export const templateVariables = pgTable("template_variables", {
assetId?: number;
selectionMode?: string;
taskFieldCode?: string;
// related_task_field support
relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity';
sourceFormId?: number;
sourceFieldCode?: string;
targetFormId?: number;
targetFieldCode?: string;
transform?: { type: string; params?: Record<string, any> } | string;
locale?: string;
}>().default({}),
@@ -3462,6 +3529,28 @@ export interface AutoDetectConfig {
trees: AutoDetectTree[];
}
// =====================
// Idempotency Keys (защита от дублей при тяжёлых batch-операциях)
// =====================
export const idempotencyKeys = pgTable("idempotency_keys", {
id: serial("id").primaryKey(),
key: varchar("key", { length: 255 }).notNull(),
organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
endpoint: varchar("endpoint", { length: 255 }).notNull(),
payloadHash: varchar("payload_hash", { length: 64 }).notNull(),
responseJson: jsonb("response_json").notNull(),
createdAt: timestamp("created_at").defaultNow(),
expiresAt: timestamp("expires_at").notNull(),
}, (table) => ({
orgKeyIdx: uniqueIndex("idempotency_keys_org_key_idx").on(table.organizationId, table.key),
expiresAtIdx: index("idempotency_keys_expires_at_idx").on(table.expiresAt),
}));
export type IdempotencyKey = typeof idempotencyKeys.$inferSelect;
export type InsertIdempotencyKey = typeof idempotencyKeys.$inferInsert;
export const FINANCE_COLORS = [
"#2563eb", "#e11d48", "#16a34a", "#f59e0b",
"#8b5cf6", "#06b6d4", "#ea580c", "#84cc16",