- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
import { forms, formFields, formStatuses, tasks, taskFieldValues, dataTables, externalServices, regularTableRows, type ExternalService, type InsertExternalService, type RegularTableRow, type InsertRegularTableRow } from "@shared/schema";
|
|
import { taskRelations } from "@shared/schema";
|
|
import { db } from "../db";
|
|
import { eq, and, or, asc, isNotNull, inArray } from "drizzle-orm";
|
|
import type { RelatedTreeNode } from "../storage";
|
|
import { DataTablesCoreStorage } from "./data-tables-core.storage";
|
|
export class DataTablesStorage extends DataTablesCoreStorage {
|
|
// Related Tasks (for task-type fields)
|
|
async getRelatedTasks(taskId: number, organizationId: number): Promise<{
|
|
parents: RelatedTreeNode[];
|
|
children: RelatedTreeNode[];
|
|
}> {
|
|
const getTaskInfo = async (id: number) => {
|
|
const [row] = await db
|
|
.select({
|
|
id: tasks.id,
|
|
title: tasks.title,
|
|
formId: tasks.formId,
|
|
formName: forms.name,
|
|
isFinal: formStatuses.isFinal,
|
|
statusName: formStatuses.name,
|
|
statusColor: formStatuses.color,
|
|
})
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
.leftJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
|
|
.where(and(eq(tasks.id, id), eq(forms.organizationId, organizationId)));
|
|
return row;
|
|
};
|
|
|
|
const buildParentTree = async (nodeId: number, pathVisited: Set<number>, depth: number): Promise<RelatedTreeNode[]> => {
|
|
if (depth >= 10) return [];
|
|
const rels = await db
|
|
.select({ parentTaskId: taskRelations.parentTaskId, fieldName: formFields.name })
|
|
.from(taskRelations)
|
|
.leftJoin(formFields, eq(taskRelations.fieldId, formFields.id))
|
|
.where(and(eq(taskRelations.childTaskId, nodeId), eq(taskRelations.organizationId, organizationId)));
|
|
|
|
const result: RelatedTreeNode[] = [];
|
|
for (const rel of rels) {
|
|
if (pathVisited.has(rel.parentTaskId)) continue;
|
|
const info = await getTaskInfo(rel.parentTaskId);
|
|
if (!info) continue;
|
|
const branchVisited = new Set(pathVisited);
|
|
branchVisited.add(rel.parentTaskId);
|
|
const nodes = await buildParentTree(rel.parentTaskId, branchVisited, depth + 1);
|
|
result.push({ ...info, isFinal: info.isFinal ?? false, fieldName: rel.fieldName ?? 'Связь из чата', nodes });
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const buildChildTree = async (nodeId: number, pathVisited: Set<number>, depth: number): Promise<RelatedTreeNode[]> => {
|
|
if (depth >= 10) return [];
|
|
const rels = await db
|
|
.select({ childTaskId: taskRelations.childTaskId, fieldName: formFields.name })
|
|
.from(taskRelations)
|
|
.leftJoin(formFields, eq(taskRelations.fieldId, formFields.id))
|
|
.where(and(eq(taskRelations.parentTaskId, nodeId), eq(taskRelations.organizationId, organizationId)));
|
|
|
|
const result: RelatedTreeNode[] = [];
|
|
for (const rel of rels) {
|
|
if (pathVisited.has(rel.childTaskId)) continue;
|
|
const info = await getTaskInfo(rel.childTaskId);
|
|
if (!info) continue;
|
|
const branchVisited = new Set(pathVisited);
|
|
branchVisited.add(rel.childTaskId);
|
|
const nodes = await buildChildTree(rel.childTaskId, branchVisited, depth + 1);
|
|
result.push({ ...info, isFinal: info.isFinal ?? false, fieldName: rel.fieldName ?? 'Связь из чата', nodes });
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const rootVisited = new Set<number>([taskId]);
|
|
const [relParents, relChildren] = await Promise.all([
|
|
buildParentTree(taskId, new Set(rootVisited), 0),
|
|
buildChildTree(taskId, new Set(rootVisited), 0),
|
|
]);
|
|
|
|
const [taskRow] = await db
|
|
.select({ parentTaskId: tasks.parentTaskId })
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
.where(and(eq(tasks.id, taskId), eq(forms.organizationId, organizationId)));
|
|
|
|
let parents = relParents;
|
|
if (taskRow?.parentTaskId) {
|
|
const alreadyIncluded = relParents.some(p => p.id === taskRow.parentTaskId);
|
|
if (!alreadyIncluded) {
|
|
const info = await getTaskInfo(taskRow.parentTaskId);
|
|
if (info) {
|
|
const hierarchyNodes = await buildParentTree(taskRow.parentTaskId, new Set([taskId, taskRow.parentTaskId]), 0);
|
|
parents = [
|
|
{ ...info, isFinal: info.isFinal ?? false, fieldName: 'Иерархия', nodes: hierarchyNodes },
|
|
...relParents,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
const subtaskRows = await db
|
|
.select({ id: tasks.id })
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
.where(and(eq(tasks.parentTaskId, taskId), eq(forms.organizationId, organizationId)));
|
|
|
|
let children = relChildren;
|
|
|
|
// --- GROUP relChildren by their parentTaskId (intermediate nodes) ---
|
|
if (relChildren.length > 0) {
|
|
const relChildIds = relChildren.map(c => c.id);
|
|
const relChildParentTaskIds = relChildIds.length > 0
|
|
? await db.select({ id: tasks.id, parentTaskId: tasks.parentTaskId })
|
|
.from(tasks)
|
|
.where(and(inArray(tasks.id, relChildIds), eq(forms.organizationId, organizationId)))
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
: [];
|
|
|
|
const grouped = new Map<number, RelatedTreeNode[]>();
|
|
const ungrouped: RelatedTreeNode[] = [];
|
|
|
|
console.log('[DEBUG] relChildIds:', relChildIds, 'relChildParentTaskIds:', JSON.stringify(relChildParentTaskIds));
|
|
|
|
for (const child of relChildren) {
|
|
const ppt = relChildParentTaskIds.find(p => p.id === child.id)?.parentTaskId;
|
|
console.log('[DEBUG] child.id:', child.id, 'ppt:', ppt, 'taskId:', taskId);
|
|
if (ppt && ppt !== taskId) {
|
|
if (!grouped.has(ppt)) grouped.set(ppt, []);
|
|
grouped.get(ppt)!.push(child);
|
|
} else {
|
|
ungrouped.push(child);
|
|
}
|
|
}
|
|
|
|
const newRelChildren: RelatedTreeNode[] = [...ungrouped];
|
|
for (const [intermediateId, childNodes] of grouped) {
|
|
const intInfo = await getTaskInfo(intermediateId);
|
|
if (intInfo) {
|
|
newRelChildren.push({
|
|
...intInfo,
|
|
isFinal: intInfo.isFinal ?? false,
|
|
fieldName: 'Иерархия',
|
|
nodes: childNodes,
|
|
});
|
|
}
|
|
}
|
|
children = newRelChildren;
|
|
}
|
|
|
|
// --- extraChildren via parentTaskId, grouped by their task_relations parent ---
|
|
const relChildIdsSet = new Set(children.map(c => c.id));
|
|
const hierarchySubtasks = subtaskRows.filter(s => !relChildIdsSet.has(s.id));
|
|
|
|
if (hierarchySubtasks.length > 0) {
|
|
const hcIds = hierarchySubtasks.map(h => h.id);
|
|
const hcRelationParents = hcIds.length > 0
|
|
? await db.select({ childTaskId: taskRelations.childTaskId, parentTaskId: taskRelations.parentTaskId, fieldName: formFields.name })
|
|
.from(taskRelations)
|
|
.leftJoin(formFields, eq(taskRelations.fieldId, formFields.id))
|
|
.where(and(inArray(taskRelations.childTaskId, hcIds), eq(taskRelations.organizationId, organizationId)))
|
|
: [];
|
|
|
|
const extraChildren: RelatedTreeNode[] = [];
|
|
const grouped = new Map<number, RelatedTreeNode[]>();
|
|
const ungrouped: RelatedTreeNode[] = [];
|
|
|
|
for (const sub of hierarchySubtasks) {
|
|
const info = await getTaskInfo(sub.id);
|
|
if (!info) continue;
|
|
const subNodes = await buildChildTree(sub.id, new Set([taskId, sub.id]), 0);
|
|
const node: RelatedTreeNode = { ...info, isFinal: info.isFinal ?? false, fieldName: 'Иерархия', nodes: subNodes };
|
|
|
|
const rel = hcRelationParents.find(r => r.childTaskId === sub.id);
|
|
if (rel && rel.parentTaskId !== taskId) {
|
|
if (!grouped.has(rel.parentTaskId)) grouped.set(rel.parentTaskId, []);
|
|
grouped.get(rel.parentTaskId)!.push(node);
|
|
} else {
|
|
ungrouped.push(node);
|
|
}
|
|
}
|
|
|
|
for (const [intermediateId, childNodes] of grouped) {
|
|
const intInfo = await getTaskInfo(intermediateId);
|
|
if (intInfo) {
|
|
extraChildren.push({
|
|
...intInfo,
|
|
isFinal: intInfo.isFinal ?? false,
|
|
fieldName: 'Связь из чата',
|
|
nodes: childNodes,
|
|
});
|
|
}
|
|
}
|
|
|
|
children = [...children, ...ungrouped, ...extraChildren];
|
|
}
|
|
|
|
const ownFieldLinks = await db
|
|
.select({
|
|
linkedTaskId: taskFieldValues.value,
|
|
taskRelationType: formFields.taskRelationType,
|
|
fieldName: formFields.name,
|
|
})
|
|
.from(taskFieldValues)
|
|
.innerJoin(formFields, eq(taskFieldValues.fieldId, formFields.id))
|
|
.where(and(
|
|
eq(taskFieldValues.taskId, taskId),
|
|
eq(formFields.type, 'task'),
|
|
isNotNull(formFields.taskRelationType),
|
|
));
|
|
|
|
const parseLinkedId = (raw: unknown): number | null => {
|
|
if (raw === null || raw === undefined || raw === '') return null;
|
|
if (typeof raw === 'number') return isNaN(raw) ? null : Math.trunc(raw);
|
|
const s = String(raw);
|
|
try {
|
|
const parsed = JSON.parse(s);
|
|
const n = parseInt(String(parsed));
|
|
return isNaN(n) ? null : n;
|
|
} catch {
|
|
const n = parseInt(s);
|
|
return isNaN(n) ? null : n;
|
|
}
|
|
};
|
|
|
|
const collectTreeIds = (nodes: RelatedTreeNode[]): number[] => {
|
|
const ids: number[] = [];
|
|
for (const node of nodes) {
|
|
ids.push(node.id);
|
|
ids.push(...collectTreeIds(node.nodes));
|
|
}
|
|
return ids;
|
|
};
|
|
|
|
const parentIds = new Set(collectTreeIds(parents));
|
|
const childIds = new Set(collectTreeIds(children));
|
|
|
|
for (const link of ownFieldLinks) {
|
|
const linkedId = parseLinkedId(link.linkedTaskId);
|
|
if (!linkedId) continue;
|
|
if (link.taskRelationType === 'parent' && !parentIds.has(linkedId)) {
|
|
const info = await getTaskInfo(linkedId);
|
|
if (info) {
|
|
parents = [...parents, { ...info, isFinal: info.isFinal ?? false, fieldName: link.fieldName ?? 'Связанная задача', nodes: [] }];
|
|
parentIds.add(linkedId);
|
|
}
|
|
} else if (link.taskRelationType === 'child' && !childIds.has(linkedId)) {
|
|
const info = await getTaskInfo(linkedId);
|
|
if (info) {
|
|
children = [...children, { ...info, isFinal: info.isFinal ?? false, fieldName: link.fieldName ?? 'Связанная задача', nodes: [] }];
|
|
childIds.add(linkedId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const taskIdStr = String(taskId);
|
|
const taskIdJsonStr = JSON.stringify(taskIdStr);
|
|
const inboundFieldLinksWithValue = await db
|
|
.select({
|
|
sourceTaskId: taskFieldValues.taskId,
|
|
linkedTaskValue: taskFieldValues.value,
|
|
taskRelationType: formFields.taskRelationType,
|
|
fieldName: formFields.name,
|
|
})
|
|
.from(taskFieldValues)
|
|
.innerJoin(formFields, eq(taskFieldValues.fieldId, formFields.id))
|
|
.where(and(
|
|
eq(formFields.type, 'task'),
|
|
isNotNull(formFields.taskRelationType),
|
|
or(
|
|
eq(taskFieldValues.value, taskIdStr),
|
|
eq(taskFieldValues.value, taskIdJsonStr),
|
|
),
|
|
));
|
|
|
|
for (const link of inboundFieldLinksWithValue) {
|
|
if (link.taskRelationType === 'parent' && !childIds.has(link.sourceTaskId)) {
|
|
const info = await getTaskInfo(link.sourceTaskId);
|
|
if (info) {
|
|
children = [...children, { ...info, isFinal: info.isFinal ?? false, fieldName: link.fieldName ?? 'Связанная задача', nodes: [] }];
|
|
childIds.add(link.sourceTaskId);
|
|
}
|
|
} else if (link.taskRelationType === 'child' && !parentIds.has(link.sourceTaskId)) {
|
|
const info = await getTaskInfo(link.sourceTaskId);
|
|
if (info) {
|
|
parents = [...parents, { ...info, isFinal: info.isFinal ?? false, fieldName: link.fieldName ?? 'Связанная задача', nodes: [] }];
|
|
parentIds.add(link.sourceTaskId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { parents, children };
|
|
}
|
|
|
|
// External Services
|
|
async getExternalServices(organizationId: number): Promise<ExternalService[]> {
|
|
return await db
|
|
.select()
|
|
.from(externalServices)
|
|
.where(eq(externalServices.organizationId, organizationId))
|
|
.orderBy(asc(externalServices.serviceName));
|
|
}
|
|
|
|
async getExternalService(id: number, organizationId: number): Promise<ExternalService | undefined> {
|
|
const [service] = await db
|
|
.select()
|
|
.from(externalServices)
|
|
.where(and(
|
|
eq(externalServices.id, id),
|
|
eq(externalServices.organizationId, organizationId)
|
|
));
|
|
return service || undefined;
|
|
}
|
|
|
|
async getExternalServiceByType(serviceType: string, organizationId: number): Promise<ExternalService | undefined> {
|
|
const [service] = await db
|
|
.select()
|
|
.from(externalServices)
|
|
.where(and(
|
|
eq(externalServices.serviceType, serviceType),
|
|
eq(externalServices.organizationId, organizationId),
|
|
eq(externalServices.isActive, true)
|
|
));
|
|
return service || undefined;
|
|
}
|
|
|
|
async createExternalService(service: InsertExternalService): Promise<ExternalService> {
|
|
const [created] = await db
|
|
.insert(externalServices)
|
|
.values(service)
|
|
.returning();
|
|
return created;
|
|
}
|
|
|
|
async updateExternalService(id: number, organizationId: number, updates: Partial<ExternalService>): Promise<ExternalService> {
|
|
const [updated] = await db
|
|
.update(externalServices)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(externalServices.id, id),
|
|
eq(externalServices.organizationId, organizationId)
|
|
))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
|
|
async deleteExternalService(id: number, organizationId: number): Promise<void> {
|
|
await db
|
|
.delete(externalServices)
|
|
.where(and(
|
|
eq(externalServices.id, id),
|
|
eq(externalServices.organizationId, organizationId)
|
|
));
|
|
}
|
|
|
|
// === Regular Table Rows ===
|
|
async getRegularTableRows(taskId: number, tabId: number): Promise<RegularTableRow[]> {
|
|
return db
|
|
.select()
|
|
.from(regularTableRows)
|
|
.where(and(
|
|
eq(regularTableRows.taskId, taskId),
|
|
eq(regularTableRows.tabId, tabId)
|
|
))
|
|
.orderBy(asc(regularTableRows.position));
|
|
}
|
|
|
|
async getRegularTableRow(id: number, taskId: number, tabId: number): Promise<RegularTableRow | undefined> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(regularTableRows)
|
|
.where(and(
|
|
eq(regularTableRows.id, id),
|
|
eq(regularTableRows.taskId, taskId),
|
|
eq(regularTableRows.tabId, tabId)
|
|
));
|
|
return row || undefined;
|
|
}
|
|
|
|
async createRegularTableRow(row: InsertRegularTableRow): Promise<RegularTableRow> {
|
|
const existingRows = await this.getRegularTableRows(row.taskId, row.tabId);
|
|
const maxPosition = existingRows.length > 0
|
|
? Math.max(...existingRows.map(r => r.position))
|
|
: -1;
|
|
|
|
const [created] = await db
|
|
.insert(regularTableRows)
|
|
.values({
|
|
...row,
|
|
position: row.position ?? maxPosition + 1,
|
|
})
|
|
.returning();
|
|
return created;
|
|
}
|
|
|
|
async updateRegularTableRow(id: number, taskId: number, tabId: number, updates: Partial<RegularTableRow>): Promise<RegularTableRow> {
|
|
const [updated] = await db
|
|
.update(regularTableRows)
|
|
.set({ ...updates, updatedAt: new Date() })
|
|
.where(and(
|
|
eq(regularTableRows.id, id),
|
|
eq(regularTableRows.taskId, taskId),
|
|
eq(regularTableRows.tabId, tabId)
|
|
))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw new Error("Строка таблицы не найдена");
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async deleteRegularTableRow(id: number, taskId: number, tabId: number): Promise<void> {
|
|
await db
|
|
.delete(regularTableRows)
|
|
.where(and(
|
|
eq(regularTableRows.id, id),
|
|
eq(regularTableRows.taskId, taskId),
|
|
eq(regularTableRows.tabId, tabId)
|
|
));
|
|
}
|
|
}
|