Files
iistwin/server/storage/data-tables.storage.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

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)
));
}
}