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,209 @@
import { db } from "../db";
import { tasks, taskFieldValues, taskRelations, formFields, forms } from "@shared/schema";
import { eq, and, sql, inArray, isNull } from "drizzle-orm";
import { storage } from "../storage";
export type GanttFieldRole = "start" | "end" | "milestone";
export interface GanttFieldInfo {
id: number;
formId: number;
role: GanttFieldRole;
}
export interface GanttTaskDates {
taskId: number;
formId: number;
start: Date | null;
end: Date | null;
milestones: Array<{ fieldId: number; date: Date }>;
}
function parseDateValue(value: unknown): Date | null {
if (value === null || value === undefined || value === "" || value === "__auto_prolongation__") return null;
if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
if (typeof value === "string") {
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
return null;
}
export function calculateDateShift(oldValue: Date | null | undefined, newValue: Date | null | undefined): number {
const oldDate = oldValue ? new Date(oldValue).getTime() : 0;
const newDate = newValue ? new Date(newValue).getTime() : 0;
return newDate - oldDate;
}
export async function getGanttFieldsByForm(formIds: number[], organizationId: number): Promise<GanttFieldInfo[]> {
if (formIds.length === 0) return [];
const rows = await db
.select({ id: formFields.id, formId: formFields.formId, ganttRole: formFields.ganttRole })
.from(formFields)
.innerJoin(forms, eq(formFields.formId, forms.id))
.where(and(
inArray(formFields.formId, formIds),
eq(forms.organizationId, organizationId),
sql`${formFields.ganttRole} IS NOT NULL`
));
return rows
.filter((r): r is typeof r & { ganttRole: GanttFieldRole } =>
r.ganttRole === "start" || r.ganttRole === "end" || r.ganttRole === "milestone"
)
.map((r) => ({ id: r.id, formId: r.formId, role: r.ganttRole }));
}
export async function getGanttTaskDates(
taskIds: number[],
ganttFields: GanttFieldInfo[],
organizationId: number
): Promise<GanttTaskDates[]> {
if (taskIds.length === 0 || ganttFields.length === 0) return [];
const fieldIds = ganttFields.map((f) => f.id);
const values = await db
.select({ taskId: taskFieldValues.taskId, fieldId: taskFieldValues.fieldId, value: taskFieldValues.value })
.from(taskFieldValues)
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(
inArray(taskFieldValues.taskId, taskIds),
inArray(taskFieldValues.fieldId, fieldIds),
eq(forms.organizationId, organizationId)
));
const valueMap = new Map<string, unknown>();
for (const v of values) {
valueMap.set(`${v.taskId}:${v.fieldId}`, v.value);
}
const result: GanttTaskDates[] = [];
for (const taskId of taskIds) {
const task = await storage.getTask(taskId, organizationId);
if (!task) continue;
const startField = ganttFields.find((f) => f.formId === task.formId && f.role === "start");
const endField = ganttFields.find((f) => f.formId === task.formId && f.role === "end");
const milestoneFields = ganttFields.filter((f) => f.formId === task.formId && f.role === "milestone");
const start = startField ? parseDateValue(valueMap.get(`${taskId}:${startField.id}`)) : null;
const end = endField ? parseDateValue(valueMap.get(`${taskId}:${endField.id}`)) : null;
const milestones = milestoneFields
.map((f) => ({ fieldId: f.id, date: parseDateValue(valueMap.get(`${taskId}:${f.id}`)) }))
.filter((m): m is { fieldId: number; date: Date } => m.date !== null);
result.push({ taskId, formId: task.formId, start, end, milestones });
}
return result;
}
export async function getRelatedTaskIds(taskId: number, organizationId: number): Promise<number[]> {
const related = new Set<number>();
// taskRelations (both directions)
const relations = await db
.select({ parentTaskId: taskRelations.parentTaskId, childTaskId: taskRelations.childTaskId })
.from(taskRelations)
.where(and(
eq(taskRelations.organizationId, organizationId),
sql`(${taskRelations.parentTaskId} = ${taskId} OR ${taskRelations.childTaskId} = ${taskId})`
));
for (const r of relations) {
if (r.parentTaskId !== taskId) related.add(r.parentTaskId);
if (r.childTaskId !== taskId) related.add(r.childTaskId);
}
// parent/child hierarchy
const task = await storage.getTask(taskId, organizationId);
if (task) {
if (task.parentTaskId) related.add(task.parentTaskId);
const children = await db
.select({ id: tasks.id })
.from(tasks)
.where(eq(tasks.parentTaskId, taskId));
for (const c of children) related.add(c.id);
}
return Array.from(related);
}
export async function shiftRelatedTasks(
sourceTaskId: number,
deltaMs: number,
organizationId: number,
options?: { triggeredByUserId?: number | null; skipAudit?: boolean }
): Promise<{ shifted: number; errors: string[] }> {
if (deltaMs === 0) return { shifted: 0, errors: [] };
const ganttFields = await getGanttFieldsByForm([], organizationId);
// Actually we need fields for all forms that have gantt fields; fetch all org gantt fields.
const allGanttFields = await db
.select({ id: formFields.id, formId: formFields.formId, ganttRole: formFields.ganttRole })
.from(formFields)
.innerJoin(forms, eq(formFields.formId, forms.id))
.where(and(
eq(forms.organizationId, organizationId),
sql`${formFields.ganttRole} IS NOT NULL`
));
const validFields: GanttFieldInfo[] = allGanttFields
.filter((r): r is typeof r & { ganttRole: GanttFieldRole } =>
r.ganttRole === "start" || r.ganttRole === "end" || r.ganttRole === "milestone"
)
.map((r) => ({ id: r.id, formId: r.formId, role: r.ganttRole }));
const visited = new Set<number>();
const queue: number[] = [sourceTaskId];
const shiftedTaskIds: number[] = [];
const errors: string[] = [];
while (queue.length > 0) {
const currentId = queue.shift()!;
if (visited.has(currentId)) continue;
visited.add(currentId);
if (currentId !== sourceTaskId) {
const currentDates = await getGanttTaskDates([currentId], validFields, organizationId);
if (currentDates.length === 0) continue;
const dates = currentDates[0];
const startField = validFields.find((f) => f.formId === dates.formId && f.role === "start");
const endField = validFields.find((f) => f.formId === dates.formId && f.role === "end");
try {
if (startField && dates.start) {
const newStart = new Date(dates.start.getTime() + deltaMs);
await storage.updateTaskFieldValue(currentId, startField.id, organizationId, {
value: newStart.toISOString(),
});
}
if (endField && dates.end) {
const newEnd = new Date(dates.end.getTime() + deltaMs);
await storage.updateTaskFieldValue(currentId, endField.id, organizationId, {
value: newEnd.toISOString(),
});
}
shiftedTaskIds.push(currentId);
} catch (err: any) {
errors.push(`Не удалось сдвинуть задачу #${currentId}: ${err.message || err}`);
continue;
}
}
const relatedIds = await getRelatedTaskIds(currentId, organizationId);
for (const id of relatedIds) {
if (!visited.has(id)) queue.push(id);
}
}
// Invalidate caches for shifted tasks
if (shiftedTaskIds.length > 0) {
const { tasksMinimalCache } = await import("../utils/cache");
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
}
return { shifted: shiftedTaskIds.length, errors };
}