MCP: инструменты справочников (data tables) — этап 1

- 15 инструментов: list/get_directory, list_directory_rows (flat/tree), get_directory_row, get_directory_column_values (read); create/bulk_create_directory_row (write); create/update/delete_directory, update/delete/move_directory_row, link/unlink_directories (full)
- Объектные проверки isTableAllowed по scopes.tableIds
- Логика дерева строк вынесена в server/utils/data-table-tree.ts (переиспользуется REST-эндпоинтом и MCP)
- bulkCreateDataTableRows: поддержка parentId и position
This commit is contained in:
2026-07-21 17:02:04 +03:00
parent a7733b4a03
commit 002ce152be
5 changed files with 834 additions and 145 deletions

View File

@@ -4,6 +4,7 @@ import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit";
import { buildDataTableTree } from "../utils/data-table-tree";
import {
getDirectoryFolders,
createDirectoryFolder,
@@ -503,157 +504,20 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return res.status(403).json({ error: 'Нет доступа' });
}
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
const treeColumnIndex = table.treeColumnIndex;
const treeDisplayColumnIndex = table.treeDisplayColumnIndex;
const rows = await storage.getDataTableRows(tableId, req.organizationId!);
const search = String(req.query.search || '').toLowerCase();
let filters: Record<string, string> | undefined;
const filtersStr = req.query.filters as string;
if (filtersStr) {
try {
const filters: Record<string, string> = JSON.parse(filtersStr);
Object.entries(filters).forEach(([col, val]) => {
const ci = parseInt(col);
if (!isNaN(ci) && val) {
rows = rows.filter((r) => {
const v = Array.isArray(r.values) ? r.values : [];
return String(v[ci] || '').toLowerCase() === String(val).toLowerCase();
});
}
});
filters = JSON.parse(filtersStr);
} catch {}
}
const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []);
const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]);
const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0;
const getRowLabel = (row: typeof rows[number]): string => {
const values = Array.isArray(row.values) ? row.values : [];
// Последнее заполненное значение из treeDisplayColumns
for (let i = treeDisplayColumns.length - 1; i >= 0; i--) {
const val = String(values[treeDisplayColumns[i]] ?? '').trim();
if (val) return val;
}
// Fallback: первая заполненная колонка
for (const val of values) {
const str = String(val ?? '').trim();
if (str) return str;
}
return '(без названия)';
};
type TreeNode =
| { type: 'group'; id: string; label: string; level: number; children: TreeNode[] }
| { type: 'row'; id: number; label: string; level: number; row: typeof rows[number] };
const buildParentTree = (parentRows: typeof rows, parentId: number | null, level = 0): TreeNode[] => {
const items = parentRows
.filter((r) => (r.parentId ?? null) === parentId)
.sort((a, b) => a.position - b.position);
return items.map((row) => {
const children = buildParentTree(parentRows, row.id, level + 1);
const label = getRowLabel(row);
if (children.length > 0) {
return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] };
}
return { type: 'row' as const, id: row.id, label, level, row };
});
};
const buildMultiColumnTree = (allRows: typeof rows, columns: number[], level = 0): TreeNode[] => {
if (level >= columns.length) {
return allRows
.sort((a, b) => a.position - b.position)
.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row }));
}
const columnIndex = columns[level];
const groups = new Map<string, typeof rows[number][]>();
const rootRows: typeof rows[number][] = [];
for (const row of allRows) {
const vals = Array.isArray(row.values) ? row.values : [];
const key = String(vals[columnIndex] ?? '').trim();
if (!key) {
rootRows.push(row);
continue;
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const nodes: TreeNode[] = [];
for (const [label, groupRows] of groups) {
// Если у группы все строки имеют пустое значение в treeDisplayColumns,
// группа становится конечной строкой (можно выбрать в задачах)
const allEmpty = groupRows.every((row) => {
const vals = Array.isArray(row.values) ? row.values : [];
return treeDisplayColumns.every((ci) => !String(vals[ci] ?? '').trim());
});
if (allEmpty && groupRows.length === 1) {
nodes.push({
type: 'row',
id: groupRows[0].id,
label,
level,
row: groupRows[0],
});
} else {
nodes.push({
type: 'group',
id: `group:${level}:${label}`,
label,
level,
children: buildMultiColumnTree(groupRows, columns, level + 1),
});
}
}
if (rootRows.length > 0) {
nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1));
}
return nodes.sort((a, b) => {
const al = a.type === 'group' ? a.label : getRowLabel(a.row);
const bl = b.type === 'group' ? b.label : getRowLabel(b.row);
return al.localeCompare(bl, 'ru');
});
};
const filterNodes = (nodes: TreeNode[], query: string): TreeNode[] => {
if (!query) return nodes;
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type === 'row') {
const label = node.label.toLowerCase();
if (label.includes(query)) result.push(node);
} else {
const children = filterNodes(node.children, query);
if (children.length > 0) {
result.push({ ...node, children });
} else if (node.label.toLowerCase().includes(query)) {
result.push({ ...node, children: [] });
}
}
}
return result;
};
const rawNodes = treeColumns.length > 0
? buildMultiColumnTree(rows, treeColumns)
: buildParentTree(rows, null);
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
res.json({
columnIndex: treeColumnIndex,
treeColumnIndex,
treeColumns,
treeDisplayColumns,
displayColumnIndex,
nodes,
});
// Логика построения дерева вынесена в server/utils/data-table-tree.ts
// (переиспользуется также MCP-сервером).
res.json(buildDataTableTree(table, rows, { filters, search }));
} catch (error) {
console.error('Get table rows tree error:', error);
res.status(500).json({ error: 'Ошибка получения дерева строк' });