- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
619 lines
22 KiB
TypeScript
619 lines
22 KiB
TypeScript
import express from 'express';
|
|
import path from 'path';
|
|
import { storage } from "../storage";
|
|
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
|
import { logAudit } from "../utils/audit";
|
|
import {
|
|
getDirectoryFolders,
|
|
createDirectoryFolder,
|
|
updateDirectoryFolder,
|
|
deleteDirectoryFolder,
|
|
moveDirectoryToFolder,
|
|
reorderDirectoryFolders,
|
|
} from "../storage/folder.storage";
|
|
|
|
export function registerDataTableRoutes(app: import("express").Express): void {
|
|
// =====================================================
|
|
// DATA TABLES API (Справочники / Directories)
|
|
// =====================================================
|
|
|
|
/**
|
|
* @swagger
|
|
* /api/tables:
|
|
* get:
|
|
* tags: [Tables]
|
|
* summary: Список справочников (data tables)
|
|
* responses:
|
|
* 200:
|
|
* description: Справочники организации
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/Table'
|
|
* post:
|
|
* tags: [Tables]
|
|
* summary: Создать справочник (admin)
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [name, code]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* code:
|
|
* type: string
|
|
* columns:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* responses:
|
|
* 201:
|
|
* description: Справочник создан
|
|
* /api/tables/{id}:
|
|
* get:
|
|
* tags: [Tables]
|
|
* summary: Получить справочник
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Данные справочника
|
|
* patch:
|
|
* tags: [Tables]
|
|
* summary: Обновить справочник
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* columns:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* responses:
|
|
* 200:
|
|
* description: Обновлён
|
|
* delete:
|
|
* tags: [Tables]
|
|
* summary: Удалить справочник (admin)
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Удалён
|
|
* /api/tables/{tableId}/rows:
|
|
* get:
|
|
* tags: [Tables]
|
|
* summary: Строки справочника
|
|
* parameters:
|
|
* - name: tableId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* - name: search
|
|
* in: query
|
|
* schema:
|
|
* type: string
|
|
* - name: page
|
|
* in: query
|
|
* schema:
|
|
* type: integer
|
|
* - name: limit
|
|
* in: query
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Строки
|
|
* post:
|
|
* tags: [Tables]
|
|
* summary: Добавить строку
|
|
* parameters:
|
|
* - name: tableId
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* description: Map columnCode → value
|
|
* responses:
|
|
* 201:
|
|
* description: Строка добавлена
|
|
*/
|
|
// Get all directories/tables accessible to current user
|
|
app.get(['/api/tables', '/api/directories'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tables = await storage.getDataTablesWithAccess(req.user!.id, req.organizationId!);
|
|
res.json({ tables });
|
|
} catch (error) {
|
|
console.error('Get tables error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения списка таблиц' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// =====================================================
|
|
// DIRECTORY FOLDERS (must be defined BEFORE /api/directories/:id)
|
|
// =====================================================
|
|
app.get('/api/directories/folders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const folders = await getDirectoryFolders(req.organizationId!);
|
|
res.json({ success: true, folders });
|
|
} catch (error) {
|
|
console.error('Get directory folders error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при получении папок' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/directories/folders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const { name, parentId, position } = req.body;
|
|
if (!name || typeof name !== 'string') {
|
|
return res.status(400).json({ success: false, error: 'Название папки обязательно' });
|
|
}
|
|
const folder = await createDirectoryFolder({
|
|
organizationId: req.organizationId!,
|
|
name,
|
|
parentId: parentId ?? null,
|
|
position: position ?? 0,
|
|
});
|
|
res.status(201).json({ success: true, folder });
|
|
} catch (error) {
|
|
console.error('Create directory folder error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при создании папки' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/directories/folders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const { name, parentId, position } = req.body;
|
|
const folder = await updateDirectoryFolder(id, req.organizationId!, { name, parentId, position });
|
|
if (!folder) {
|
|
return res.status(404).json({ success: false, error: 'Папка не найдена' });
|
|
}
|
|
res.json({ success: true, folder });
|
|
} catch (error) {
|
|
console.error('Update directory folder error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при обновлении папки' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/directories/folders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
await deleteDirectoryFolder(id, req.organizationId!);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Delete directory folder error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при удалении папки' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/directories/folders/reorder', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const { updates } = req.body;
|
|
if (!Array.isArray(updates)) {
|
|
return res.status(400).json({ success: false, error: 'Некорректные данные' });
|
|
}
|
|
await reorderDirectoryFolders(req.organizationId!, updates);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Reorder directory folders error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при изменении порядка' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/directories/:id/folder', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
const { folderId } = req.body;
|
|
const table = await moveDirectoryToFolder(id, req.organizationId!, folderId ?? null);
|
|
if (!table) {
|
|
return res.status(404).json({ success: false, error: 'Справочник не найден' });
|
|
}
|
|
res.json({ success: true, table });
|
|
} catch (error) {
|
|
console.error('Move directory to folder error:', error);
|
|
res.status(500).json({ success: false, error: 'Ошибка при перемещении справочника' });
|
|
}
|
|
});
|
|
|
|
// Get single directory/table with rows
|
|
app.get(['/api/tables/:id', '/api/directories/:id'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.id);
|
|
const table = await storage.getDataTableFull(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role) {
|
|
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
|
}
|
|
|
|
res.json({ table, role });
|
|
} catch (error) {
|
|
console.error('Get table error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create directory/table
|
|
app.post(['/api/tables', '/api/directories'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const { name, description, columns } = req.body;
|
|
|
|
if (!name || !columns || columns.length === 0) {
|
|
return res.status(400).json({ error: 'Название и колонки обязательны' });
|
|
}
|
|
|
|
const table = await storage.createDataTable({
|
|
name,
|
|
description,
|
|
columns,
|
|
organizationId: req.organizationId!,
|
|
createdBy: req.user!.id
|
|
});
|
|
|
|
res.status(201).json({ table });
|
|
} catch (error) {
|
|
console.error('Create table error:', error);
|
|
res.status(500).json({ error: 'Ошибка создания таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Update directory/table
|
|
app.patch(['/api/tables/:id', '/api/directories/:id'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.id);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (role !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
|
|
}
|
|
|
|
const { name, description, columns } = req.body;
|
|
const updates: Record<string, unknown> = {};
|
|
if (name !== undefined) updates.name = name;
|
|
if (description !== undefined) updates.description = description;
|
|
if (columns !== undefined) updates.columns = columns;
|
|
|
|
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
|
|
res.json({ table: updatedTable });
|
|
} catch (error) {
|
|
console.error('Update table error:', error);
|
|
res.status(500).json({ error: 'Ошибка обновления таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete directory/table
|
|
app.delete(['/api/tables/:id', '/api/directories/:id'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.id);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (role !== 'admin') {
|
|
return res.status(403).json({ error: 'Нет прав для удаления таблицы' });
|
|
}
|
|
|
|
await storage.deleteDataTable(tableId, req.organizationId!);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Delete table error:', error);
|
|
res.status(500).json({ error: 'Ошибка удаления таблицы' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// =====================================================
|
|
// DATA TABLE ROWS API
|
|
// =====================================================
|
|
|
|
// Column values for directories (autocomplete for filters)
|
|
app.get(['/api/tables/:tableId/column-values', '/api/directories/:tableId/column-values'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const columnIdx = parseInt(req.query.column as string);
|
|
const search = ((req.query.search as string) || '').toLowerCase();
|
|
const contextStr = req.query.context as string;
|
|
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
if (!table) return res.status(404).json({ error: 'Таблица не найдена' });
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role) return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
|
|
|
|
if (contextStr) {
|
|
try {
|
|
const context: Record<string, string> = JSON.parse(contextStr);
|
|
Object.entries(context).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().includes(val.toLowerCase());
|
|
});
|
|
}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const values: string[] = [];
|
|
for (const row of rows) {
|
|
const rv = Array.isArray(row.values) ? row.values : [];
|
|
const val = String(rv[columnIdx] || '').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('Column values error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения значений' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get directory/table rows (with optional filter/sort)
|
|
app.get(['/api/tables/:tableId/rows', '/api/directories/:tableId/rows'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role) {
|
|
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
|
}
|
|
|
|
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
|
|
|
|
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().includes(val.toLowerCase());
|
|
});
|
|
}
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
const sortColumn = req.query.sortColumn as string;
|
|
const sortDir = req.query.sortDirection as string;
|
|
if (sortColumn !== undefined && (sortDir === 'asc' || sortDir === 'desc')) {
|
|
const ci = parseInt(sortColumn);
|
|
if (!isNaN(ci)) {
|
|
rows = [...rows].sort((a, b) => {
|
|
const av = Array.isArray(a.values) ? a.values : [];
|
|
const bv = Array.isArray(b.values) ? b.values : [];
|
|
const aStr = String(av[ci] || '').toLowerCase();
|
|
const bStr = String(bv[ci] || '').toLowerCase();
|
|
const cmp = aStr.localeCompare(bStr, 'ru');
|
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ rows });
|
|
} catch (error) {
|
|
console.error('Get table rows error:', error);
|
|
res.status(500).json({ error: 'Ошибка получения строк' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Create table row
|
|
app.post(['/api/tables/:tableId/rows', '/api/directories/:tableId/rows'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role || role === 'reader') {
|
|
return res.status(403).json({ error: 'Нет прав для добавления строк' });
|
|
}
|
|
|
|
const { values } = req.body;
|
|
if (!values || !Array.isArray(values)) {
|
|
return res.status(400).json({ error: 'Значения обязательны' });
|
|
}
|
|
|
|
const row = await storage.createDataTableRow({ tableId, values });
|
|
res.status(201).json({ row });
|
|
} catch (error) {
|
|
console.error('Create table row error:', error);
|
|
res.status(500).json({ error: 'Ошибка создания строки' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Update table row
|
|
app.patch(['/api/tables/:tableId/rows/:rowId', '/api/directories/:tableId/rows/:rowId'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const rowId = parseInt(req.params.rowId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role || role === 'reader') {
|
|
return res.status(403).json({ error: 'Нет прав для редактирования строк' });
|
|
}
|
|
|
|
const { values } = req.body;
|
|
if (!values || !Array.isArray(values)) {
|
|
return res.status(400).json({ error: 'Значения обязательны' });
|
|
}
|
|
|
|
const row = await storage.updateDataTableRow(rowId, tableId, req.organizationId!, { values });
|
|
res.json({ row });
|
|
} catch (error) {
|
|
console.error('Update table row error:', error);
|
|
res.status(500).json({ error: 'Ошибка обновления строки' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete table row
|
|
app.delete(['/api/tables/:tableId/rows/:rowId', '/api/directories/:tableId/rows/:rowId'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const rowId = parseInt(req.params.rowId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role || role === 'reader') {
|
|
return res.status(403).json({ error: 'Нет прав для удаления строк' });
|
|
}
|
|
|
|
await storage.deleteDataTableRow(rowId, tableId, req.organizationId!);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Delete table row error:', error);
|
|
res.status(500).json({ error: 'Ошибка удаления строки' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Log data table Excel/CSV export
|
|
app.post(['/api/tables/:tableId/export', '/api/directories/:tableId/export'],
|
|
authenticateToken,
|
|
tenantIsolation,
|
|
async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const tableId = parseInt(req.params.tableId);
|
|
const table = await storage.getDataTable(tableId, req.organizationId!);
|
|
|
|
if (!table) {
|
|
return res.status(404).json({ error: 'Таблица не найдена' });
|
|
}
|
|
|
|
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
|
|
if (!role) {
|
|
return res.status(403).json({ error: 'Нет доступа к таблице' });
|
|
}
|
|
|
|
const exportFormat: string = req.body?.format ?? 'xlsx';
|
|
logAudit({
|
|
action: `export.table_${exportFormat}`,
|
|
userId: req.user!.id,
|
|
organizationId: req.organizationId!,
|
|
details: {
|
|
tableId,
|
|
tableName: table.name,
|
|
format: exportFormat,
|
|
},
|
|
ip: req.ip ?? null,
|
|
userAgent: req.headers['user-agent'] ?? null,
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Table export log error:', error);
|
|
res.status(500).json({ error: 'Ошибка записи события экспорта' });
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|