fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
209
server/services/gantt-shift.service.ts
Normal file
209
server/services/gantt-shift.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user