443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
import { db } from "../db";
|
|
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";
|
|
import * as documentStorage from "../storage/documents.storage";
|
|
|
|
export interface ResolutionContext {
|
|
task: typeof tasks.$inferSelect;
|
|
fieldValues: Array<{
|
|
code: string;
|
|
value: unknown;
|
|
type: string;
|
|
name: string;
|
|
}>;
|
|
assignee: typeof users.$inferSelect | null;
|
|
creator: typeof users.$inferSelect | null;
|
|
organization: typeof organizations.$inferSelect | null;
|
|
currentUser: typeof users.$inferSelect | null;
|
|
}
|
|
|
|
export interface ResolutionResult {
|
|
resolved: Record<string, string>;
|
|
unresolved: string[];
|
|
}
|
|
|
|
export class DataResolutionService {
|
|
private transform = new TransformService();
|
|
|
|
async resolveAll(
|
|
variables: TemplateVariable[],
|
|
taskId: number,
|
|
organizationId: number,
|
|
currentUserId: number,
|
|
): Promise<ResolutionResult> {
|
|
const resolved: Record<string, string> = {};
|
|
const unresolved: string[] = [];
|
|
|
|
const task = await this.getTask(taskId);
|
|
if (!task) throw new Error(`Задача ${taskId} не найдена`);
|
|
|
|
const fieldValues = await this.getFieldValues(taskId);
|
|
const assignee = task.assignedTo ? await this.getUser(task.assignedTo) : null;
|
|
const creator = task.createdBy ? await this.getUser(task.createdBy) : null;
|
|
const organization = await this.getOrganization(organizationId);
|
|
const currentUser = await this.getUser(currentUserId);
|
|
|
|
const context: ResolutionContext = {
|
|
task,
|
|
fieldValues,
|
|
assignee,
|
|
creator,
|
|
organization,
|
|
currentUser,
|
|
};
|
|
|
|
for (const variable of variables) {
|
|
try {
|
|
const value = await this.resolveVariable(variable, context, organizationId);
|
|
resolved[variable.code] = value ?? '';
|
|
if (value === null || value === undefined || value === '') {
|
|
unresolved.push(variable.code);
|
|
}
|
|
} catch {
|
|
unresolved.push(variable.code);
|
|
resolved[variable.code] = '';
|
|
}
|
|
}
|
|
|
|
return { resolved, unresolved };
|
|
}
|
|
|
|
private async getTask(taskId: number) {
|
|
const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
|
return task;
|
|
}
|
|
|
|
private async getFieldValues(taskId: number) {
|
|
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));
|
|
}
|
|
|
|
private async getUser(userId: number) {
|
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
return user || null;
|
|
}
|
|
|
|
private async getOrganization(organizationId: number) {
|
|
const [org] = await db.select().from(organizations).where(eq(organizations.id, organizationId)).limit(1);
|
|
return org || null;
|
|
}
|
|
|
|
private async resolveVariable(variable: TemplateVariable, context: ResolutionContext, organizationId: number): Promise<string | null> {
|
|
const mapping = (variable.mapping || {}) as {
|
|
fieldCode?: string;
|
|
formId?: number;
|
|
systemKey?: string;
|
|
staticValue?: string;
|
|
userId?: number;
|
|
relatedTaskId?: number;
|
|
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;
|
|
|
|
switch (variable.source) {
|
|
case 'task_field': {
|
|
const fv = context.fieldValues.find((f) => f.code === mapping.fieldCode);
|
|
rawValue = fv ? this.extractFieldDisplayValue(fv.value, fv.type) : null;
|
|
break;
|
|
}
|
|
case 'task_system': {
|
|
rawValue = mapping.systemKey ? this.getTaskField(context.task, mapping.systemKey) : null;
|
|
break;
|
|
}
|
|
case 'assignee': {
|
|
rawValue = context.assignee ? this.getUserField(context.assignee, mapping.systemKey || 'fullName') : null;
|
|
break;
|
|
}
|
|
case 'creator': {
|
|
rawValue = context.creator ? this.getUserField(context.creator, mapping.systemKey || 'fullName') : null;
|
|
break;
|
|
}
|
|
case 'organization': {
|
|
rawValue = context.organization ? this.getOrgField(context.organization, mapping.systemKey || 'name') : null;
|
|
break;
|
|
}
|
|
case 'current_user': {
|
|
rawValue = context.currentUser ? this.getUserField(context.currentUser, mapping.systemKey || 'fullName') : null;
|
|
break;
|
|
}
|
|
case 'static': {
|
|
rawValue = mapping.staticValue ?? variable.defaultValue ?? null;
|
|
break;
|
|
}
|
|
case 'current_date': {
|
|
rawValue = new Date().toISOString();
|
|
break;
|
|
}
|
|
case 'related_task_field': {
|
|
rawValue = await this.resolveRelatedTaskField(
|
|
variable,
|
|
mapping,
|
|
context,
|
|
organizationId,
|
|
);
|
|
break;
|
|
}
|
|
case 'task_table_tab': {
|
|
rawValue = await this.resolveTableTab(context.task.id, mapping.tabId);
|
|
break;
|
|
}
|
|
case 'asset': {
|
|
rawValue = await this.resolveAsset(
|
|
organizationId,
|
|
mapping,
|
|
context.task.id,
|
|
context.fieldValues,
|
|
);
|
|
break;
|
|
}
|
|
default:
|
|
rawValue = null;
|
|
}
|
|
|
|
if (rawValue === null || rawValue === undefined) {
|
|
rawValue = variable.defaultValue || '';
|
|
}
|
|
|
|
return this.transform.applyTransform(rawValue, mapping.transform);
|
|
}
|
|
|
|
private extractFieldDisplayValue(value: unknown, fieldType: string): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
|
|
if (typeof value === 'string') return value;
|
|
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
|
|
if (typeof value === 'object') {
|
|
const obj = value as Record<string, unknown>;
|
|
|
|
// Company / Contact / Task / Geo — пробуем взять label/name/title
|
|
if (typeof obj.displayName === 'string') return obj.displayName;
|
|
if (typeof obj.name === 'string') return obj.name;
|
|
if (typeof obj.title === 'string') return obj.title;
|
|
if (typeof obj.label === 'string') return obj.label;
|
|
if (typeof obj.value === 'string') return obj.value;
|
|
if (typeof obj.value === 'number') return String(obj.value);
|
|
|
|
// Date / DateTime могут быть объектом { date: '...' }
|
|
if (typeof obj.date === 'string') return obj.date;
|
|
|
|
// Select / Multi-select
|
|
if (Array.isArray(obj.selectedOptions)) {
|
|
return obj.selectedOptions.map((o: any) => o.label || o.value || o).join(', ');
|
|
}
|
|
|
|
// File
|
|
if (Array.isArray(obj.files)) {
|
|
return obj.files.map((f: any) => f.name || f.url || '').filter(Boolean).join(', ');
|
|
}
|
|
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
private getTaskField(task: typeof tasks.$inferSelect, key: string): string | null {
|
|
switch (key) {
|
|
case 'id':
|
|
return String(task.id);
|
|
case 'title':
|
|
return task.title;
|
|
case 'description':
|
|
return task.description ?? '';
|
|
case 'createdAt':
|
|
return task.createdAt ? new Date(task.createdAt).toISOString() : '';
|
|
case 'dueDate':
|
|
return task.dueDate ? new Date(task.dueDate).toISOString() : '';
|
|
case 'completedAt':
|
|
return task.completedAt ? new Date(task.completedAt).toISOString() : '';
|
|
case 'status':
|
|
return task.currentStatusId ? String(task.currentStatusId) : '';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private getUserField(user: typeof users.$inferSelect, key: string): string | null {
|
|
switch (key) {
|
|
case 'fullName':
|
|
return `${user.lastName || ''} ${user.firstName || ''} ${user.middleName || ''}`.trim() || user.email || '';
|
|
case 'lastName':
|
|
return user.lastName || '';
|
|
case 'firstName':
|
|
return user.firstName || '';
|
|
case 'middleName':
|
|
return user.middleName || '';
|
|
case 'position':
|
|
return user.position || '';
|
|
case 'email':
|
|
return user.email || '';
|
|
case 'initials': {
|
|
const parts = [`${user.lastName || ''} ${user.firstName || ''} ${user.middleName || ''}`.trim()];
|
|
if (parts[0].split(/\s+/).length >= 2) {
|
|
return this.transform.applyTransform(parts[0], 'initials');
|
|
}
|
|
return parts[0];
|
|
}
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private getOrgField(org: typeof organizations.$inferSelect, key: string): string | null {
|
|
switch (key) {
|
|
case 'name':
|
|
return org.name || '';
|
|
case 'displayName':
|
|
return org.displayName || '';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private async resolveTableTab(taskId: number, tabId?: number): Promise<string | null> {
|
|
if (!tabId) return null;
|
|
|
|
const tab = await db.query.formTabs.findFirst({
|
|
where: eq(formTabs.id, tabId),
|
|
with: { form: true },
|
|
});
|
|
if (!tab) return null;
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(regularTableRows)
|
|
.where(and(eq(regularTableRows.taskId, taskId), eq(regularTableRows.tabId, tabId)))
|
|
.orderBy(asc(regularTableRows.position));
|
|
|
|
const columns = (tab.tableColumns as Array<{ id: string; name: string; type: string }> | undefined) || [];
|
|
|
|
return JSON.stringify({
|
|
tabName: tab.name,
|
|
columns: columns.map((c) => ({ id: c.id, name: c.name, type: c.type })),
|
|
rows: rows.map((r) => (r.data as Record<string, unknown>)),
|
|
});
|
|
}
|
|
|
|
private async resolveAsset(
|
|
organizationId: number,
|
|
mapping: Record<string, any>,
|
|
taskId: number,
|
|
fieldValues: Array<{ code: string; value: unknown; type: string; name: string }>,
|
|
): Promise<string | null> {
|
|
const assetType = mapping.assetType as string | undefined;
|
|
const assetId = mapping.assetId ? Number(mapping.assetId) : undefined;
|
|
const formId = mapping.formId ? Number(mapping.formId) : undefined;
|
|
const fieldCode = mapping.fieldCode as string | undefined;
|
|
const taskFieldCode = mapping.taskFieldCode as string | undefined;
|
|
|
|
let asset: typeof import("@shared/schema").organizationAssets.$inferSelect | undefined;
|
|
|
|
if (assetId) {
|
|
asset = await documentStorage.getOrganizationAsset(assetId, organizationId);
|
|
} else if (assetType && formId && fieldCode && taskFieldCode) {
|
|
const taskValue = fieldValues.find((f) => f.code === taskFieldCode)?.value;
|
|
if (taskValue === undefined || taskValue === null || taskValue === '') return null;
|
|
asset = await documentStorage.findOrganizationAssetByFieldValue(
|
|
organizationId,
|
|
assetType,
|
|
formId,
|
|
fieldCode,
|
|
String(taskValue),
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|