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,618 @@
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: 'Ошибка записи события экспорта' });
}
}
);
}