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,774 @@
import { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { type Task, type FormStatus, type User, roles, tasks } from "@shared/schema";
import { db } from "../db";
import { eq, inArray } from "drizzle-orm";
import { tasksMinimalCache } from "../utils/cache";
function safeParseOptions(opts: unknown): unknown {
if (Array.isArray(opts)) return opts;
if (typeof opts === 'string') {
try { return JSON.parse(opts); } catch { return undefined; }
}
if (typeof opts === 'object' && opts !== null) return opts;
return undefined;
}
/** Сканирует fieldValues задач на поля типа 'task', собирает referenced task IDs и возвращает их titles */
async function buildRelatedTaskTitles(
tasksList: Array<{ fieldValues?: { [fieldId: number]: unknown } } | any>,
formFields: Array<{ id: number; type: string }>,
): Promise<Record<number, string>> {
const taskFieldIds = new Set<number>();
for (const f of formFields) {
if (f.type === 'task') taskFieldIds.add(f.id);
}
if (taskFieldIds.size === 0) return {};
const referencedIds = new Set<number>();
for (const t of tasksList) {
if (!t.fieldValues) continue;
for (const fid of taskFieldIds) {
const val = t.fieldValues[fid];
if (val == null) continue;
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) return {};
const relatedTasks = await db
.select({ id: tasks.id, title: tasks.title })
.from(tasks)
.where(inArray(tasks.id, Array.from(referencedIds)));
const map: Record<number, string> = {};
for (const t of relatedTasks) {
if (t.id) map[t.id] = t.title || `#${t.id}`;
}
return map;
}
export function registerTaskCrudListRoutes(router: ReturnType<typeof import("express").Router>): void {
router.get('/api/tasks', async (req: AuthenticatedRequest, res) => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
const sortBy = (req.query.sortBy as 'updatedAt' | 'createdAt') || 'updatedAt';
const order = (req.query.order as 'asc' | 'desc') || 'desc';
const statusId = req.query.statusId ? parseInt(req.query.statusId as string) : undefined;
const assignedTo = req.query.assignedTo ? parseInt(req.query.assignedTo as string) : undefined;
const minimal = req.query.minimal === 'true';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const appRole = req.user!.appRole;
const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole);
if (search) {
const searchLimit = limit && limit > 0 ? Math.min(limit, 100) : 50;
let tasks = await storage.searchTasksByKeyword(req.organizationId!, search, searchLimit);
if (accessibleIds !== null) {
tasks = tasks.filter((t: any) => accessibleIds.has(t.id));
}
return res.json({ success: true, tasks });
}
const cacheKey = (minimal && accessibleIds === null)
? `tasks:${req.organizationId}:minimal:${limit ?? ''}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}`
: null;
if (cacheKey) {
const cached = tasksMinimalCache.get<Task[]>(cacheKey);
if (cached) {
return res.json({ success: true, tasks: cached });
}
}
let tasks = await storage.getTasksByOrganization(req.organizationId!, {
limit: limit && limit > 0 ? Math.min(limit, 100) : undefined,
sortBy,
order,
statusId: statusId && !isNaN(statusId) ? statusId : undefined,
assignedTo: assignedTo && !isNaN(assignedTo) ? assignedTo : undefined,
minimal
});
if (accessibleIds !== null) {
tasks = (tasks as Task[]).filter((t: Task) => accessibleIds.has(t.id));
}
if (cacheKey) {
tasksMinimalCache.set(cacheKey, tasks);
}
res.json({ success: true, tasks });
} catch (error) {
console.error('Get all tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const appRole = req.user!.appRole;
const [tasks, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByForm(formId, req.organizationId!),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
const filteredTasks = accessibleIds !== null
? tasks.filter((t: Task) => accessibleIds.has(t.id))
: tasks;
const formFields = await storage.getFormFields(formId, req.organizationId!);
const relatedTaskTitles = await buildRelatedTaskTitles(filteredTasks, formFields);
res.json({ success: true, tasks: filteredTasks, relatedTaskTitles });
} catch (error) {
console.error('Get tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks/with-fields', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, { limit, offset }),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
let tasks = result.tasks as Task[];
if (accessibleIds !== null) {
tasks = tasks.filter((t: Task) => accessibleIds.has(t.id));
}
// Build taskTitleMap early for filtering/sorting and field resolution
const taskTitleMap = new Map<number, string>();
for (const t of tasks) {
if (t.id) taskTitleMap.set(t.id, t.title);
}
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
if (Object.keys(filters).length > 0) {
const [statuses, users, formFields, orgRoles] = await Promise.all([
storage.getFormStatuses(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: User) => [u.id, u]));
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch {}
}
Object.entries(filters).forEach(([col, val]) => {
if (!val) return;
if (col === 'overdue' && val === 'true') {
const today = new Date();
today.setHours(0, 0, 0, 0);
tasks = tasks.filter((t: Task) => {
const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal;
return !isCompleted && t.dueDate && new Date(t.dueDate) < today;
});
return;
}
const valLower = val.toLowerCase();
tasks = tasks.filter((t: Task) => {
const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap);
return tv.toLowerCase().includes(valLower);
});
});
}
} catch {}
}
const sortColumn = req.query.sortColumn as string;
const sortDir = req.query.sortDirection as string;
if (sortColumn && (sortDir === 'asc' || sortDir === 'desc')) {
const [statuses, users, formFields, orgRoles] = await Promise.all([
storage.getFormStatuses(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: User) => [u.id, u]));
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch {}
}
const DATE_COLS = new Set(['createdAt', 'updatedAt', 'dueDate', 'completedAt']);
tasks = [...tasks].sort((a, b) => {
if (DATE_COLS.has(sortColumn)) {
const _a = (a as Record<string, unknown>)[sortColumn];
const _b = (b as Record<string, unknown>)[sortColumn];
const av = _a ? new Date(String(_a)).getTime() : 0;
const bv = _b ? new Date(String(_b)).getTime() : 0;
return sortDir === 'asc' ? av - bv : bv - av;
}
const av = getTaskColumnValue(a, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase();
const bv = getTaskColumnValue(b, sortColumn, statusMap, userMap, formFields, roleMap, undefined, legalEntityMap, taskTitleMap).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDir === 'asc' ? cmp : -cmp;
});
}
const formFields = await storage.getFormFields(formId, req.organizationId!);
const relatedTaskTitles = await buildRelatedTaskTitles(tasks, formFields);
res.json({ success: true, tasks, total: tasks.length, limit, offset, relatedTaskTitles });
} catch (error) {
console.error('Get tasks with fields error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задач' });
}
});
router.get('/api/forms/:id/tasks/status-counts', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const data = await storage.getTaskCountsByStatus(formId, req.organizationId!);
res.json({ success: true, data });
} catch (error) {
console.error('Get status counts error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении количества задач по статусам' });
}
});
function getTaskColumnValue(
task: Task & { fieldValues?: Record<number, unknown>; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> },
column: string,
statusMap: Map<number, FormStatus>,
userMap: Map<number, User>,
fields?: Array<{ id: number; type: string; options?: unknown }>,
roleMap?: Map<number, { name?: string }>,
tableRowMap?: Map<number, Map<number, string>>,
legalEntityMap?: Map<number, string>,
taskTitleMap?: Map<number, string>
): string {
if (column === 'title') return task.title || '';
if (column === 'status') {
const s = statusMap.get(task.currentStatusId);
return s ? s.name : '';
}
if (column === 'currentStatusId') {
return String(task.currentStatusId || '');
}
if (column === 'assignedTo') {
const taskAny = task as Task & { assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> };
if (taskAny.assignees && Array.isArray(taskAny.assignees) && taskAny.assignees.length > 0) {
return taskAny.assignees
.map((a: { userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }) => {
const u = a.user ?? userMap.get(a.userId);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
})
.filter(Boolean)
.join(', ');
}
if (!task.assignedTo) return '';
const u = userMap.get(task.assignedTo);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
}
if (column === 'createdBy') {
if (!task.createdBy) return '';
const u = userMap.get(task.createdBy);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : '';
}
if (column === 'dueDate') {
return task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : '';
}
if (column === 'createdAt') {
return task.createdAt ? new Date(task.createdAt).toLocaleDateString('ru-RU') : '';
}
if (column === 'updatedAt') {
return task.updatedAt ? new Date(task.updatedAt).toLocaleDateString('ru-RU') : '';
}
if (column === 'completedAt') {
return task.completedAt ? new Date(task.completedAt).toLocaleDateString('ru-RU') : '';
}
if (column.startsWith('field_')) {
const fieldId = parseInt(column.replace('field_', ''));
const field = fields?.find(f => f.id === fieldId);
const fv = task.fieldValues;
if (fv && typeof fv === 'object') {
const v = fv[fieldId];
if (v !== undefined && v !== null) {
const str = String(v);
switch (field?.type) {
case 'user': {
if (str.startsWith('user:')) {
const uid = Number(str.replace('user:', ''));
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
}
const uid = Number(str);
if (!isNaN(uid)) {
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
}
return str;
}
case 'role': {
if (str.startsWith('role:')) {
const rid = Number(str.replace('role:', ''));
const r = roleMap?.get(rid);
return r?.name || str;
}
const rid = Number(str);
if (!isNaN(rid)) {
const r = roleMap?.get(rid);
return r?.name || str;
}
return str;
}
case 'contract-number': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
const num = parsed?.value ? String(parsed.value) : str;
const entityTitle = parsed?.legalEntityTaskId ? legalEntityMap?.get(Number(parsed.legalEntityTaskId)) : undefined;
const result = entityTitle ? `${num} — ${entityTitle}` : num;
if (process.env.NODE_ENV !== 'production') {
console.log('[getTaskColumnValue] contract-number:', 'fieldId:', fieldId, 'parsed:', JSON.stringify(parsed), 'entityTitle:', entityTitle, 'result:', result);
}
return result;
} catch {
return str;
}
}
case 'company': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
return parsed?.name || parsed?.shortName || parsed?.fullName || str;
} catch {
return str;
}
}
case 'select':
case 'radio-group': {
const parsedOpts = safeParseOptions(field.options);
const opts = Array.isArray(parsedOpts) ? parsedOpts as Array<{ label?: string; value?: string; id?: string }> : [];
const opt = opts.find(o => String(o.value ?? o.id) === str);
return opt?.label || str;
}
case 'table': {
const parsedOpts = safeParseOptions(field.options);
const tableOpts = (typeof parsedOpts === 'object' && parsedOpts !== null && !Array.isArray(parsedOpts))
? parsedOpts as { tableId?: number; displayColumn?: string }
: undefined;
const tableId = tableOpts?.tableId;
// Значение может быть числом/строкой или JSON-объектом {rowId: n}
let rowId = Number(str);
if (isNaN(rowId) && typeof v === 'object' && v !== null) {
try {
const obj = typeof v === 'string' ? JSON.parse(v) : v;
if (obj && typeof obj.rowId === 'number') {
rowId = obj.rowId;
}
} catch { /* ignore */ }
}
if (tableId && !isNaN(rowId) && tableRowMap?.has(tableId)) {
const displayValue = tableRowMap.get(tableId)!.get(rowId);
if (displayValue !== undefined) return displayValue;
}
return str;
}
case 'toggle':
case 'checkbox': {
if (v === true || str === 'true') return 'Да';
if (v === false || str === 'false') return 'Нет';
return str;
}
case 'checklist': {
if (Array.isArray(v)) {
const checkedCount = v.filter((it: any) => it?.checked).length;
const totalCount = Array.isArray(field.options) ? field.options.length : 0;
return `Выполнено ${checkedCount} из ${totalCount}`;
}
return str;
}
case 'file': {
if (Array.isArray(v)) {
return v
.map(f => typeof f === 'object' && f !== null ? (f as { name?: string }).name || '' : String(f))
.filter(Boolean)
.join(', ');
}
if (typeof v === 'object' && v !== null) {
return (v as { name?: string }).name || str;
}
return str;
}
case 'geo': {
try {
const parsed = typeof v === 'string' ? JSON.parse(v) : v;
return parsed?.address || `${parsed?.lat || ''}, ${parsed?.lng || ''}` || str;
} catch {
return str;
}
}
case 'date':
case 'datetime': {
const dateValue = typeof v === 'string' ? v : str;
if (!dateValue) return '';
const date = new Date(dateValue);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('ru-RU');
}
return dateValue;
}
case 'task': {
const taskId = typeof v === 'number' ? v : Number(str);
if (!isNaN(taskId)) {
const title = taskTitleMap?.get(taskId);
if (title) return title;
return `#${taskId}`;
}
return str;
}
}
// Fallback для file-like объектов
if (Array.isArray(v)) {
return v
.filter((f: unknown): f is { name: string } => typeof f === 'object' && f !== null && typeof (f as { name?: unknown }).name === 'string')
.map((f: { name: string }) => f.name)
.join(', ');
}
if (typeof v === 'object' && v !== null && 'name' in v && typeof (v as Record<string, unknown>).name === 'string') {
return (v as Record<string, string>).name;
}
return String(v);
}
}
return '';
}
return '';
}
router.get('/api/forms/:id/tasks/column-values', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) return res.status(400).json({ error: 'Неверный ID формы' });
const column = (req.query.column as string) || '';
const search = ((req.query.search as string) || '').toLowerCase();
const contextStr = req.query.context as string;
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) return res.status(404).json({ error: 'Форма не найдена' });
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
type TaskWithFieldValues = Task & { fieldValues?: Record<number, unknown> };
let taskList = result.tasks as TaskWithFieldValues[];
if (accessibleIds !== null) {
taskList = taskList.filter((t: TaskWithFieldValues) => accessibleIds.has(t.id));
}
const [formFields, orgRoles] = await Promise.all([
storage.getFormFields(formId, req.organizationId!),
db.select().from(roles).where(eq(roles.organizationId, req.organizationId!)),
]);
const roleMap = new Map(orgRoles.map((r) => [r.id, r]));
// Build taskTitleMap for task field resolution
const taskTitleMap = new Map<number, string>();
for (const t of taskList) {
if (t.id) taskTitleMap.set(t.id, t.title);
}
const relatedTaskTitles = await buildRelatedTaskTitles(taskList, formFields);
for (const [id, title] of Object.entries(relatedTaskTitles)) {
const taskId = Number(id);
if (!taskTitleMap.has(taskId)) {
taskTitleMap.set(taskId, title);
}
}
// Загружаем юрлица для полей типа 'contract-number'
const contractNumberFields = formFields.filter((f) => f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId);
console.log('[column-values] contractNumberFields count:', contractNumberFields.length, 'formId:', formId);
for (const f of contractNumberFields) {
console.log('[column-values] contract-number field:', f.id, f.name, 'options:', JSON.stringify(f.options));
}
const legalEntityMap = new Map<number, string>();
for (const f of contractNumberFields) {
const leFormId = (f as any).options.contractNumber.legalEntityFormId as number;
console.log('[column-values] loading legal entities for formId:', leFormId, 'org:', req.organizationId);
try {
const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!);
console.log('[column-values] loaded legal entities:', leTasks.length);
for (const t of leTasks) {
legalEntityMap.set(t.id, t.title);
}
} catch (err) {
console.error('[column-values] error loading legal entities for formId:', leFormId, err);
}
}
console.log('[column-values] legalEntityMap size:', legalEntityMap.size);
// Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия
const tableRowMap = new Map<number, Map<number, string>>();
const tableFields = formFields.filter((f: any) => {
if (f.type !== 'table') return false;
const parsed = safeParseOptions(f.options);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (parsed as any).tableId;
});
const uniqueTableIds = new Set<number>();
for (const f of tableFields) {
const parsed = safeParseOptions(f.options);
const tableId = (parsed as any).tableId as number;
if (tableId) uniqueTableIds.add(tableId);
}
for (const tableId of uniqueTableIds) {
const fullTable = await storage.getDataTableFull(tableId, req.organizationId!);
if (!fullTable) continue;
const columns = (fullTable.columns as Array<{ name: string }>) || [];
const rows = (fullTable.rows as Array<{ id: number; values: string[] }>) || [];
const displayColumn = (tableFields.find((f: any) => (f.options as any).tableId === tableId)?.options as any)?.displayColumn || columns[0]?.name;
const colIndex = columns.findIndex((c) => c.name === displayColumn);
const rowMap = new Map<number, string>();
for (const row of rows) {
const displayValue = colIndex >= 0 ? row.values[colIndex] : row.values[0];
rowMap.set(row.id, displayValue || `#${row.id}`);
}
tableRowMap.set(tableId, rowMap);
}
if (contextStr) {
try {
const context: Record<string, string> = JSON.parse(contextStr);
const statuses = await storage.getFormStatuses(formId, req.organizationId!);
const users = await storage.getUsersByOrganization(req.organizationId!);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: User) => [u.id, u]));
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
if (col === 'overdue' && val === 'true') {
const today = new Date();
today.setHours(0, 0, 0, 0);
taskList = taskList.filter((t: Task) => {
const isCompleted = t.isCompleted || statusMap.get(t.currentStatusId)?.isFinal;
return !isCompleted && t.dueDate && new Date(t.dueDate) < today;
});
return;
}
const valLower = val.toLowerCase();
taskList = taskList.filter((t: Task) => {
const tv = getTaskColumnValue(t, col, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap);
return tv.toLowerCase().includes(valLower);
});
});
} catch {}
}
const statuses = await storage.getFormStatuses(formId, req.organizationId!);
const users = await storage.getUsersByOrganization(req.organizationId!);
const statusMap = new Map(statuses.map((s: FormStatus) => [s.id, s]));
const userMap = new Map(users.map((u: User) => [u.id, u]));
// Отладка: логируем select/radio-group/table поля и их options
if (column.startsWith('field_')) {
const dbgFieldId = parseInt(column.replace('field_', ''));
const dbgField = formFields.find(f => f.id === dbgFieldId);
if (dbgField && (dbgField.type === 'select' || dbgField.type === 'radio-group' || dbgField.type === 'table')) {
console.log('[column-values] field:', dbgField.id, 'type:', dbgField.type, 'options:', JSON.stringify(dbgField.options), 'optionsType:', typeof dbgField.options);
// Логируем первую задачу с ненулевым значением
const firstTask = taskList.find(t => t.fieldValues && t.fieldValues[dbgField.id] !== undefined && t.fieldValues[dbgField.id] !== null);
if (firstTask) {
const rawV = firstTask.fieldValues![dbgField.id];
const resolved = getTaskColumnValue(firstTask, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap);
console.log('[column-values] firstTaskId:', firstTask.id, 'rawV:', JSON.stringify(rawV), 'rawVType:', typeof rawV, 'resolved:', resolved);
}
}
}
const seen = new Set<string>();
const values: string[] = [];
for (const t of taskList) {
const val = getTaskColumnValue(t, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap).trim();
if (!val || seen.has(val)) continue;
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= 20) break;
}
res.json({ values });
} catch (error) {
console.error('Task column values error:', error);
res.status(500).json({ error: 'Ошибка получения значений' });
}
});
router.post('/api/forms/:id/tasks/aggregate', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const { fieldCode, operation, statusId } = req.body;
if (!fieldCode || !operation) {
return res.status(400).json({ success: false, error: 'Требуется fieldCode и operation' });
}
const allowedOperations = ['sum', 'avg', 'count', 'min', 'max'];
if (!allowedOperations.includes(operation)) {
return res.status(400).json({
success: false,
error: `Неверная операция. Допустимые: ${allowedOperations.join(', ')}`
});
}
const appRole = req.user!.appRole;
const [result, accessibleIds] = await Promise.all([
storage.getTasksWithFieldsByFormOptimized(formId, req.organizationId!, {}),
storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole),
]);
type TaskWithFields = (typeof result.tasks)[0] & { fieldValues?: Record<number, string | number | null> };
let tasks = result.tasks as TaskWithFields[];
if (accessibleIds !== null) {
tasks = tasks.filter((t: Task) => accessibleIds.has(t.id));
}
let filteredTasks = tasks;
if (statusId) {
filteredTasks = tasks.filter(t => t.currentStatusId === statusId);
}
const values: number[] = [];
for (const task of filteredTasks) {
const fieldValue = task.fieldValues?.[fieldCode];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
const numValue = parseFloat(String(fieldValue));
if (!isNaN(numValue)) {
values.push(numValue);
}
}
}
let aggregatedValue: number | null = null;
switch (operation) {
case 'sum':
aggregatedValue = values.reduce((a, b) => a + b, 0);
break;
case 'avg':
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case 'count':
aggregatedValue = values.length;
break;
case 'min':
aggregatedValue = values.length > 0 ? Math.min(...values) : null;
break;
case 'max':
aggregatedValue = values.length > 0 ? Math.max(...values) : null;
break;
}
res.json({
success: true,
result: {
formId,
fieldCode,
operation,
value: aggregatedValue,
taskCount: filteredTasks.length,
valuesCount: values.length
}
});
} catch (error) {
console.error('Aggregate tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при агрегации данных' });
}
});
}