Шаги 0.10 и 0.9 плана production-готовности: - дефолт limit 50, кап 200; списковые ответы без description - курсорная пагинация (updated_at, id) + nextCursor - since-фильтр дельта-синка перенесён в SQL (updated_at >= since) - embedding pre-count через COUNT(*) вместо выгрузки миллиона строк - 11 новых юнит-тестов (56/56 зелёные) Инвариант поиска сохранён: search-ветка /api/tasks?search= не изменена
849 lines
38 KiB
TypeScript
849 lines
38 KiB
TypeScript
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 SafeUser, roles, tasks } from "@shared/schema";
|
||
import { db } from "../db";
|
||
import { eq, inArray } from "drizzle-orm";
|
||
import { tasksMinimalCache } from "../utils/cache";
|
||
import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination";
|
||
|
||
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 requestedLimit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
|
||
// Дефолт 50, жёсткий кап 200 — без limit больше не уходит до 10000 полных строк
|
||
const effectiveLimit = clampTasksLimit(requestedLimit);
|
||
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() : '';
|
||
// Курсорная пагинация: cursor = "<updatedAt ISO>_<id>" (невалидный — игнорируется)
|
||
const cursor = parseTasksCursor(typeof req.query.cursor === 'string' ? req.query.cursor : null);
|
||
const appRole = req.user!.appRole;
|
||
|
||
const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole);
|
||
|
||
if (search) {
|
||
const searchLimit = requestedLimit && requestedLimit > 0 ? Math.min(requestedLimit, 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 });
|
||
}
|
||
|
||
// nextCursor выдаём только для дефолтной сортировки (updatedAt DESC) — режима курсора
|
||
const computeNextCursor = (list: Array<{ id: number; updatedAt?: Date | string | null }>): string | null => {
|
||
if (sortBy !== 'updatedAt' || order !== 'desc' || list.length < effectiveLimit) return null;
|
||
const last = list[list.length - 1];
|
||
return last && last.updatedAt ? buildTasksNextCursor({ id: last.id, updatedAt: last.updatedAt }) : null;
|
||
};
|
||
|
||
const cacheKey = (minimal && accessibleIds === null)
|
||
? `tasks:${req.organizationId}:minimal:${effectiveLimit}:${sortBy}:${order}:${statusId ?? ''}:${assignedTo ?? ''}:${cursor ? buildTasksNextCursor(cursor) ?? '' : ''}`
|
||
: null;
|
||
|
||
if (cacheKey) {
|
||
const cached = tasksMinimalCache.get<Task[]>(cacheKey);
|
||
if (cached) {
|
||
return res.json({ success: true, tasks: cached, nextCursor: computeNextCursor(cached) });
|
||
}
|
||
}
|
||
|
||
let tasks = await storage.getTasksByOrganization(req.organizationId!, {
|
||
limit: effectiveLimit,
|
||
sortBy,
|
||
order,
|
||
statusId: statusId && !isNaN(statusId) ? statusId : undefined,
|
||
assignedTo: assignedTo && !isNaN(assignedTo) ? assignedTo : undefined,
|
||
minimal,
|
||
// Списковый ответ без description: тяжёлая text-колонка в реестрах не нужна.
|
||
// Поиск (выше) и деталь задачи (/api/tasks/:id) description сохраняют.
|
||
excludeDescription: true,
|
||
cursor,
|
||
});
|
||
|
||
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, nextCursor: computeNextCursor(tasks as Array<{ id: number; updatedAt?: Date | string | null }>) });
|
||
} 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: SafeUser) => [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: SafeUser) => [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']);
|
||
|
||
// Числовые и датовые кастомные поля сортируем по сырому значению,
|
||
// а не по отображаемой строке (иначе «01.12.2025» < «02.01.2024» и «10» < «9»)
|
||
type TaskWithFV = Task & { fieldValues?: Record<number, unknown> };
|
||
let numericFieldId: number | null = null;
|
||
let numericFieldIsDate = false;
|
||
if (sortColumn.startsWith('field_')) {
|
||
const fieldId = parseInt(sortColumn.replace('field_', ''));
|
||
const field = formFields.find((f) => f.id === fieldId);
|
||
if (field && ['date', 'datetime', 'number'].includes(field.type)) {
|
||
numericFieldId = fieldId;
|
||
numericFieldIsDate = field.type === 'date' || field.type === 'datetime';
|
||
}
|
||
}
|
||
const toSortNumber = (t: TaskWithFV): number | null => {
|
||
const v = t.fieldValues?.[numericFieldId!];
|
||
if (v === undefined || v === null || v === '') return null;
|
||
if (numericFieldIsDate) {
|
||
const ts = new Date(String(v)).getTime();
|
||
return isNaN(ts) ? null : ts;
|
||
}
|
||
const n = Number(String(v).replace(/\s/g, ''));
|
||
return isNaN(n) ? null : n;
|
||
};
|
||
|
||
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;
|
||
}
|
||
if (numericFieldId !== null) {
|
||
const av = toSortNumber(a as TaskWithFV);
|
||
const bv = toSortNumber(b as TaskWithFV);
|
||
// Пустые значения всегда в конец независимо от направления
|
||
if (av === null && bv === null) return 0;
|
||
if (av === null) return 1;
|
||
if (bv === null) return -1;
|
||
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: 'Ошибка при получении количества задач по статусам' });
|
||
}
|
||
});
|
||
|
||
// Отображаемое имя одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число)
|
||
function resolveUserColumnEntry(
|
||
entry: string,
|
||
userMap: Map<number, SafeUser>,
|
||
roleMap?: Map<number, { name?: string }>
|
||
): string {
|
||
if (entry.startsWith('user:')) {
|
||
const uid = Number(entry.replace('user:', ''));
|
||
const u = userMap.get(uid);
|
||
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
|
||
}
|
||
if (entry.startsWith('role:')) {
|
||
const rid = Number(entry.replace('role:', ''));
|
||
const r = roleMap?.get(rid);
|
||
return r?.name ? `Роль: ${r.name}` : entry;
|
||
}
|
||
const uid = Number(entry);
|
||
if (!isNaN(uid)) {
|
||
const u = userMap.get(uid);
|
||
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
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, SafeUser>,
|
||
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': {
|
||
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
|
||
let parsedUserVal: unknown = v;
|
||
if (typeof v === 'string' && v.startsWith('[')) {
|
||
try { parsedUserVal = JSON.parse(v); } catch { parsedUserVal = v; }
|
||
}
|
||
if (Array.isArray(parsedUserVal)) {
|
||
return parsedUserVal
|
||
.map((entry) => resolveUserColumnEntry(String(entry), userMap, roleMap))
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
}
|
||
return resolveUserColumnEntry(str, userMap, roleMap);
|
||
}
|
||
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: SafeUser) => [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: SafeUser) => [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: 'Ошибка при агрегации данных' });
|
||
}
|
||
});
|
||
}
|