- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
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 };
|
||
}
|