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,377 @@
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { db } from "../db";
import { dataTables } from "@shared/schema";
import { eq, and } from "drizzle-orm";
export function registerDataTableSyncRoutes(app: import("express").Express): void {
// =====================================================
// DATA TABLE SYNC API (для импорта/синхронизации)
// =====================================================
// Sync table (полная синхронизация как в Pyrus)
app.post(['/api/tables/:tableId/sync', '/api/directories/:tableId/sync'],
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 { apply, rows } = req.body;
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ error: 'Строки обязательны' });
}
const currentRows = await storage.getDataTableRows(tableId, req.organizationId!);
const normalizeValues = (values: (string | null)[] | null, columnCount: number): string[] => {
const arr = values || [];
const result: string[] = [];
for (let i = 0; i < columnCount; i++) {
const val = arr[i];
result.push(val === null || val === undefined ? '' : String(val).trim());
}
return result;
};
const columnCount = table.columns?.length || 6;
const makeKey = (values: (string | null)[] | null) => JSON.stringify(normalizeValues(values, columnCount));
const validCurrentRows = currentRows.filter(r => Array.isArray(r.values));
const currentRowsByKey = new Map(validCurrentRows.map(r => [makeKey(r.values), r]));
const currentKeysSet = new Set(validCurrentRows.map(r => makeKey(r.values)));
const newKeysSet = new Set(rows.map((r: { values: string[] }) => makeKey(r.values)));
const added: { values: string[], position: number }[] = [];
const deleted: { id: number; values: string[] }[] = [];
const reordered: { id: number; values: string[]; oldPosition: number; newPosition: number }[] = [];
rows.forEach((newRow: { values: string[] }, index: number) => {
const key = makeKey(newRow.values);
if (!currentKeysSet.has(key)) {
added.push({ values: newRow.values, position: index });
} else {
const existingRow = currentRowsByKey.get(key);
if (existingRow && existingRow.position !== index) {
reordered.push({
id: existingRow.id,
values: normalizeValues(existingRow.values, columnCount),
oldPosition: existingRow.position,
newPosition: index
});
}
}
});
for (const currentRow of validCurrentRows) {
const key = makeKey(currentRow.values);
if (!newKeysSet.has(key)) {
deleted.push({ id: currentRow.id, values: normalizeValues(currentRow.values, columnCount) });
}
}
if (apply) {
const positionUpdates: { id: number; position: number }[] = [];
for (let i = 0; i < rows.length; i++) {
const key = makeKey(rows[i].values);
const existingRow = currentRowsByKey.get(key);
if (existingRow && !deleted.some(d => d.id === existingRow.id)) {
positionUpdates.push({ id: existingRow.id, position: i });
}
}
await storage.applyDataTableSync(
tableId,
deleted,
added,
reordered.map(r => ({ id: r.id, newPosition: r.newPosition })),
positionUpdates
);
}
res.json({
apply,
added: added.length,
deleted: deleted.length,
reordered: reordered.length,
addedRows: added.map(r => ({ values: r.values })),
deletedRows: deleted.map(r => ({ id: r.id, values: r.values })),
reorderedRows: reordered.map(r => ({ values: r.values, oldPosition: r.oldPosition, newPosition: r.newPosition }))
});
} catch (error) {
console.error('Sync table error:', error);
res.status(500).json({ error: 'Ошибка синхронизации таблицы' });
}
}
);
// Diff table (частичное обновление как в Pyrus)
app.post(['/api/tables/:tableId/diff', '/api/directories/:tableId/diff'],
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 { upsert, delete: deleteKeys } = req.body;
const currentRows = await storage.getDataTableRows(tableId, req.organizationId!);
const upsertArr = upsert && Array.isArray(upsert) ? upsert : [];
const deleteKeysArr = deleteKeys && Array.isArray(deleteKeys) ? deleteKeys : [];
const result = await storage.applyDataTableDiff(tableId, upsertArr, deleteKeysArr, currentRows);
res.json({
apply: true,
added: result.added.map(r => ({ id: r.id, values: r.values })),
updated: result.updated.map(r => ({ id: r.id, values: r.values })),
deleted: result.deleted.map(r => ({ id: r.id, values: r.values }))
});
} catch (error) {
console.error('Diff table error:', error);
res.status(500).json({ error: 'Ошибка изменения таблицы' });
}
}
);
// =====================================================
// DATA TABLE PERMISSIONS API
// =====================================================
// Get table permissions
app.get(['/api/tables/:tableId/permissions', '/api/directories/:tableId/permissions'],
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 !== 'admin') {
return res.status(403).json({ error: 'Нет прав для просмотра прав доступа' });
}
const permissions = await storage.getDataTablePermissions(tableId, req.organizationId!);
res.json({ permissions, createdBy: table.createdBy });
} catch (error) {
console.error('Get table permissions error:', error);
res.status(500).json({ error: 'Ошибка получения прав доступа' });
}
}
);
// Set table permission
app.post(['/api/tables/:tableId/permissions', '/api/directories/:tableId/permissions'],
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
}
const { userId, role } = req.body;
if (!userId || !role) {
return res.status(400).json({ error: 'ID пользователя и роль обязательны' });
}
if (!['admin', 'editor', 'reader'].includes(role)) {
return res.status(400).json({ error: 'Недопустимая роль' });
}
if (userId === table.createdBy) {
return res.status(400).json({ error: 'Нельзя изменить права создателя таблицы' });
}
const permission = await storage.setDataTablePermission({ tableId, userId, role });
res.json({ permission });
} catch (error) {
console.error('Set table permission error:', error);
res.status(500).json({ error: 'Ошибка установки прав доступа' });
}
}
);
// Delete table permission
app.delete(['/api/tables/:tableId/permissions/:userId', '/api/directories/:tableId/permissions/:userId'],
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const tableId = parseInt(req.params.tableId);
const targetUserId = parseInt(req.params.userId);
const table = await storage.getDataTable(tableId, req.organizationId!);
if (!table) {
return res.status(404).json({ error: 'Таблица не найдена' });
}
const currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
}
await storage.deleteDataTablePermission(tableId, targetUserId);
res.json({ success: true });
} catch (error) {
console.error('Delete table permission error:', error);
res.status(500).json({ error: 'Ошибка удаления прав доступа' });
}
}
);
// =====================================================
// DATA TABLE ACCESS RULES API (role-based)
// =====================================================
app.get(['/api/tables/:tableId/access-rules', '/api/directories/:tableId/access-rules'],
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для просмотра прав доступа' });
}
const rules = await storage.getDataTableAccessRules(tableId, req.organizationId!);
const linkedForms = await storage.getLinkedFormsForTable(tableId, req.organizationId!);
res.json({ rules, linkedForms });
} catch (error) {
console.error('Get table access rules error:', error);
res.status(500).json({ error: 'Ошибка получения прав доступа' });
}
}
);
app.post(['/api/tables/:tableId/access-rules', '/api/directories/:tableId/access-rules'],
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
}
const { targetType, targetId, accessLevel } = req.body;
if (!targetType || !targetId || !accessLevel) {
return res.status(400).json({ error: 'targetType, targetId и accessLevel обязательны' });
}
if (!['user', 'role'].includes(targetType) || !['reader', 'editor', 'admin'].includes(accessLevel)) {
return res.status(400).json({ error: 'Недопустимые значения targetType или accessLevel' });
}
const rule = await storage.createDataTableAccessRule({
tableId,
organizationId: req.organizationId!,
targetType,
targetId,
accessLevel,
});
res.json({ rule });
} catch (error) {
console.error('Create table access rule error:', error);
res.status(500).json({ error: 'Ошибка создания правила доступа' });
}
}
);
app.delete(['/api/tables/:tableId/access-rules/:ruleId', '/api/directories/:tableId/access-rules/:ruleId'],
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const tableId = parseInt(req.params.tableId);
const ruleId = parseInt(req.params.ruleId);
const table = await storage.getDataTable(tableId, req.organizationId!);
if (!table) return res.status(404).json({ error: 'Таблица не найдена' });
const currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для управления правами доступа' });
}
await storage.deleteDataTableAccessRule(ruleId, req.organizationId!);
res.json({ success: true });
} catch (error) {
console.error('Delete table access rule error:', error);
res.status(500).json({ error: 'Ошибка удаления правила доступа' });
}
}
);
app.patch(['/api/tables/:tableId/visibility', '/api/directories/:tableId/visibility'],
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 currentRole = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (currentRole !== 'admin') {
return res.status(403).json({ error: 'Нет прав для изменения настроек' });
}
const { visibility } = req.body;
if (!['restricted', 'organization'].includes(visibility)) {
return res.status(400).json({ error: 'Недопустимое значение visibility' });
}
const [updated] = await db.update(dataTables)
.set({ visibility })
.where(and(eq(dataTables.id, tableId), eq(dataTables.organizationId, req.organizationId!)))
.returning();
res.json({ table: updated });
} catch (error) {
console.error('Update table visibility error:', error);
res.status(500).json({ error: 'Ошибка изменения видимости' });
}
}
);
}