fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
import { Router } from "express";
import { db } from "../db";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { tasks, forms, formStatuses, taskAssignees, taskFieldValues, taskRelations, users } from "@shared/schema";
import { eq, and, or, inArray, sql, isNotNull, asc, exists } from "drizzle-orm";
import {
getGanttFieldsByForm,
getGanttTaskDates,
getRelatedTaskIds,
calculateDateShift,
shiftRelatedTasks,
type GanttFieldInfo,
type GanttTaskDates,
} from "../services/gantt-shift.service";
export const ganttRouter = Router();
interface GanttTask {
id: number;
title: string;
description?: string | null;
formId: number;
currentStatusId: number;
statusName: string | null;
statusColor: string | null;
statusIsFinal: boolean;
assignedTo?: number | null;
assignees?: Array<{ userId: number; user: { firstName: string | null; lastName: string | null; email: string } }>;
createdBy: number;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
dueDate?: string | null;
isCompleted: boolean;
parentTaskId?: number | null;
fieldValues?: Record<number, unknown>;
start: string | null;
end: string | null;
milestones: Array<{ fieldId: number; date: string }>;
}
interface GanttFormMeta {
formId: number;
startFieldId?: number;
endFieldId?: number;
milestoneFieldIds: number[];
}
function parseFormIds(value: unknown): number[] | undefined {
if (typeof value !== "string" || !value) return undefined;
const ids = value
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
return ids.length > 0 ? ids : undefined;
}
// GET /api/gantt/tasks?formIds=1,2,3&filters={...}&sortColumn=...&sortDirection=...
ganttRouter.get("/api/gantt/tasks", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formIds = parseFormIds(req.query.formIds);
if (!formIds || formIds.length === 0) {
return res.json({ success: true, tasks: [], meta: [], relatedTaskTitles: {} });
}
const organizationId = req.organizationId!;
const userId = req.user!.id;
const appRole = req.user!.appRole;
// Verify forms belong to organization and user can access them
const formRows = await db
.select({ id: forms.id, name: forms.name })
.from(forms)
.where(and(eq(forms.organizationId, organizationId), inArray(forms.id, formIds)));
const validFormIds = formRows.map((f) => f.id);
if (validFormIds.length === 0) {
return res.status(403).json({ success: false, error: "Нет доступа к выбранным формам" });
}
const accessibleIds = await storage.getAccessibleTaskIds(userId, organizationId, appRole);
// Load tasks with fields from selected forms
const taskRows = await db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
formId: tasks.formId,
currentStatusId: tasks.currentStatusId,
assignedTo: tasks.assignedTo,
createdBy: tasks.createdBy,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
completedAt: tasks.completedAt,
dueDate: tasks.dueDate,
isCompleted: tasks.isCompleted,
parentTaskId: tasks.parentTaskId,
statusName: formStatuses.name,
statusColor: formStatuses.color,
statusIsFinal: formStatuses.isFinal,
})
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
.where(and(
eq(forms.organizationId, organizationId),
inArray(tasks.formId, validFormIds)
))
.orderBy(asc(tasks.updatedAt));
let allowedTaskRows = taskRows;
if (accessibleIds !== null) {
allowedTaskRows = taskRows.filter((t) => accessibleIds.has(t.id));
}
const taskIds = allowedTaskRows.map((t) => t.id);
// Load field values
const fieldValuesRows = await db
.select({ taskId: taskFieldValues.taskId, fieldId: taskFieldValues.fieldId, value: taskFieldValues.value })
.from(taskFieldValues)
.where(inArray(taskFieldValues.taskId, taskIds));
const fieldValuesByTask = new Map<number, Record<number, unknown>>();
for (const fv of fieldValuesRows) {
if (!fieldValuesByTask.has(fv.taskId)) fieldValuesByTask.set(fv.taskId, {});
fieldValuesByTask.get(fv.taskId)![fv.fieldId] = fv.value;
}
// Load assignees
const assigneeRows = await db
.select({
taskId: taskAssignees.taskId,
userId: taskAssignees.userId,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
})
.from(taskAssignees)
.innerJoin(users, eq(taskAssignees.userId, users.id))
.where(inArray(taskAssignees.taskId, taskIds));
const assigneesByTask = new Map<number, Array<{ userId: number; user: { firstName: string | null; lastName: string | null; email: string } }>>();
for (const a of assigneeRows) {
if (!assigneesByTask.has(a.taskId)) assigneesByTask.set(a.taskId, []);
assigneesByTask.get(a.taskId)!.push({
userId: a.userId,
user: { firstName: a.firstName, lastName: a.lastName, email: a.email },
});
}
// Load gantt fields meta
const ganttFields = await getGanttFieldsByForm(validFormIds, organizationId);
const ganttDates = await getGanttTaskDates(taskIds, ganttFields, organizationId);
const datesMap = new Map<number, GanttTaskDates>();
for (const d of ganttDates) datesMap.set(d.taskId, d);
// Build meta per form
const metaByForm = new Map<number, GanttFormMeta>();
for (const formId of validFormIds) {
metaByForm.set(formId, {
formId,
startFieldId: ganttFields.find((f) => f.formId === formId && f.role === "start")?.id,
endFieldId: ganttFields.find((f) => f.formId === formId && f.role === "end")?.id,
milestoneFieldIds: ganttFields.filter((f) => f.formId === formId && f.role === "milestone").map((f) => f.id),
});
}
// Build result tasks
let resultTasks: GanttTask[] = allowedTaskRows.map((t) => {
const dates = datesMap.get(t.id);
const fvs = fieldValuesByTask.get(t.id) || {};
return {
id: t.id,
title: t.title,
description: t.description,
formId: t.formId,
currentStatusId: t.currentStatusId,
statusName: t.statusName,
statusColor: t.statusColor,
statusIsFinal: t.statusIsFinal ?? false,
assignedTo: t.assignedTo,
assignees: assigneesByTask.get(t.id),
createdBy: t.createdBy,
createdAt: t.createdAt ? t.createdAt.toISOString() : "",
updatedAt: t.updatedAt ? t.updatedAt.toISOString() : "",
completedAt: t.completedAt ? t.completedAt.toISOString() : null,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
isCompleted: t.isCompleted ?? false,
parentTaskId: t.parentTaskId,
fieldValues: fvs,
start: dates?.start ? dates.start.toISOString() : null,
end: dates?.end ? dates.end.toISOString() : null,
milestones: dates?.milestones.map((m) => ({ fieldId: m.fieldId, date: m.date.toISOString() })) || [],
};
});
// Apply filters (server-side subset)
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
// TODO: implement column filtering if needed; for now keep all tasks and let client filter.
} catch { /* ignore */ }
}
// Build related task titles for task-type fields
const relatedTaskTitles: Record<number, string> = {};
const referencedIds = new Set<number>();
for (const t of resultTasks) {
if (!t.fieldValues) continue;
for (const [fieldIdStr, val] of Object.entries(t.fieldValues)) {
const fieldId = parseInt(fieldIdStr);
const field = ganttFields.find((f) => f.id === fieldId);
// We don't have field type here; collect all numeric task refs.
let taskId: number | undefined;
if (typeof val === "number") taskId = val;
else if (typeof val === "string") {
const n = Number(val);
if (!isNaN(n)) taskId = n;
else {
try {
const parsed = JSON.parse(val);
if (parsed && typeof parsed.taskId === "number") taskId = parsed.taskId;
else if (parsed && typeof parsed.id === "number") taskId = parsed.id;
} catch { /* ignore */ }
}
} else if (typeof val === "object" && val !== null) {
const obj = val as any;
if (typeof obj.taskId === "number") taskId = obj.taskId;
else if (typeof obj.id === "number") taskId = obj.id;
}
if (taskId !== undefined) referencedIds.add(taskId);
}
}
if (referencedIds.size > 0) {
const relatedRows = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(tasks.id, Array.from(referencedIds)), eq(forms.organizationId, organizationId)));
for (const r of relatedRows) {
relatedTaskTitles[r.id] = r.title || `#${r.id}`;
}
}
res.json({
success: true,
tasks: resultTasks,
meta: Array.from(metaByForm.values()),
relatedTaskTitles,
});
} catch (error) {
console.error("Gantt tasks error:", error);
res.status(500).json({ success: false, error: "Ошибка при получении задач Гантта" });
}
});
// GET /api/gantt/relations?taskIds=1,2,3
ganttRouter.get("/api/gantt/relations", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const taskIds = parseFormIds(req.query.taskIds);
if (!taskIds || taskIds.length === 0) {
return res.json({ success: true, relations: [] });
}
const organizationId = req.organizationId!;
const relations = await db
.select({ parentTaskId: taskRelations.parentTaskId, childTaskId: taskRelations.childTaskId })
.from(taskRelations)
.where(and(
eq(taskRelations.organizationId, organizationId),
or(
inArray(taskRelations.parentTaskId, taskIds),
inArray(taskRelations.childTaskId, taskIds)
)
));
res.json({ success: true, relations });
} catch (error) {
console.error("Gantt relations error:", error);
res.status(500).json({ success: false, error: "Ошибка при получении связей Гантта" });
}
});
// PUT /api/gantt/tasks/:id/dates — update gantt dates with cascade shift
ganttRouter.put("/api/gantt/tasks/:id/dates", authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id, 10);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: "Неверный ID задачи" });
}
const { start, end, startFieldId, endFieldId } = req.body;
const organizationId = req.organizationId!;
const existingTask = await storage.getTask(taskId, organizationId);
if (!existingTask) {
return res.status(404).json({ success: false, error: "Задача не найдена" });
}
const hasAccess = await storage.canUserAccessTask(taskId, req.user!.id, organizationId, req.user!.appRole);
if (!hasAccess) {
return res.status(403).json({ success: false, error: "Нет доступа к этой задаче" });
}
const ganttFields = await getGanttFieldsByForm([existingTask.formId], organizationId);
const dates = await getGanttTaskDates([taskId], ganttFields, organizationId);
const currentDates = dates[0];
let deltaMs = 0;
const updatedFields: Array<{ fieldId: number; value: string }> = [];
if (startFieldId && start !== undefined) {
const newStart = start ? new Date(start) : null;
const oldStart = currentDates?.start || null;
if (newStart && !isNaN(newStart.getTime())) {
updatedFields.push({ fieldId: startFieldId, value: newStart.toISOString() });
if (deltaMs === 0 && oldStart) {
deltaMs = calculateDateShift(oldStart, newStart);
}
}
}
if (endFieldId && end !== undefined) {
const newEnd = end ? new Date(end) : null;
const oldEnd = currentDates?.end || null;
if (newEnd && !isNaN(newEnd.getTime())) {
updatedFields.push({ fieldId: endFieldId, value: newEnd.toISOString() });
if (deltaMs === 0 && oldEnd) {
deltaMs = calculateDateShift(oldEnd, newEnd);
}
}
}
if (updatedFields.length === 0) {
return res.status(400).json({ success: false, error: "Не переданы даты для обновления" });
}
// Apply updates to the source task
for (const { fieldId, value } of updatedFields) {
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value });
}
// Cascade shift related tasks
const shiftResult = await shiftRelatedTasks(taskId, deltaMs, organizationId, { triggeredByUserId: req.user?.id });
// Invalidate caches
const { tasksMinimalCache } = await import("../utils/cache");
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
res.json({ success: true, taskId, deltaMs, shifted: shiftResult.shifted, errors: shiftResult.errors });
} catch (error) {
console.error("Gantt update dates error:", error);
res.status(500).json({ success: false, error: "Ошибка при обновлении дат Гантта" });
}
});