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,351 @@
import { Router } from "express";
import crypto from "crypto";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit";
import { z } from "zod";
const OFFLINE_TASK_LIMIT = 500;
const initialSyncSchema = z.object({
formIds: z.array(z.number()).optional(),
});
const deltaSyncSchema = z.object({
formIds: z.array(z.number()).optional(),
since: z.string().datetime(),
});
const pushSyncSchema = z.object({
mutations: z.array(z.record(z.any())).default([]),
});
export function registerSyncRoutes(app: import("express").Express): void {
const router = Router();
// Все endpoint'ы синхронизации требуют аутентификации и tenant-изоляции
router.use(authenticateToken, tenantIsolation);
/**
* GET /api/sync/config
* Конфигурация офлайн-режима для текущего пользователя:
* - формы с включённым глобальным offlineCache
* - персональные подписки пользователя
* - автоматизации, разрешённые для офлайн-выполнения
*/
router.get('/config', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const [globalForms, subscriptions, automations] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
storage.getOfflineAutomations(organizationId),
]);
const subscribedFormIds = new Set(subscriptions.map(s => s.formId));
const forms = [
...globalForms.filter(f => !subscribedFormIds.has(f.id)),
...subscriptions.filter(s => s.isActive).map(s => s.formId),
];
const uniqueFormIds = Array.from(new Set(forms.map(f => typeof f === 'number' ? f : f.id)));
const formList = uniqueFormIds.map(id => {
const global = globalForms.find(f => f.id === id);
if (global) return global;
return { id } as const;
});
res.json({
success: true,
forms: formList,
subscriptions,
automations: automations.map(a => ({
id: a.id,
name: a.name,
trigger: a.trigger,
triggerConfig: a.triggerConfig,
runOffline: a.runOffline,
clientCompatible: a.clientCompatible,
})),
});
} catch (err: any) {
console.error('[SYNC] config error:', err);
res.status(500).json({ success: false, error: err.message || 'Ошибка получения конфигурации синхронизации' });
}
});
/**
* POST /api/sync/initial
* Начальная полная синхронизация данных для офлайн-работы.
* Возвращает метаданные форм, задачи (до 500 на форму), значения полей,
* справочники и offline-автоматизации.
*/
router.post('/initial', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = initialSyncSchema.parse(req.body);
let formIds = body.formIds;
if (!formIds || formIds.length === 0) {
const [globalForms, subscriptions] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
]);
const ids = new Set<number>();
globalForms.forEach(f => ids.add(f.id));
subscriptions.filter(s => s.isActive).forEach(s => ids.add(s.formId));
formIds = Array.from(ids);
}
// Проверяем доступ пользователя к каждой форме
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
const forms: any[] = [];
const tasks: any[] = [];
const taskFieldValues: any[] = [];
const tableIds = new Set<number>();
for (const formId of accessibleFormIds) {
const [form, formFields, formStatuses, formTabs, transitions] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
]);
if (!form) continue;
// Собираем ID справочников из полей типа table / select
for (const field of formFields) {
const f = field as any;
if (f.type === 'table' && f.linkedFormId) {
tableIds.add(f.linkedFormId);
}
if ((f.type === 'select' || f.type === 'radio-group') && f.options) {
// options может быть строкой JSON
const opts = typeof f.options === 'string' ? JSON.parse(f.options) : f.options;
if (Array.isArray(opts)) {
opts.forEach((o: any) => {
if (o.tableId) tableIds.add(o.tableId);
});
}
}
}
forms.push({
...form,
fields: formFields,
statuses: formStatuses,
tabs: formTabs,
transitions,
});
// Honor the form's offline-cache strategy: 'assigned' fetches only tasks
// assigned to the current user; 'recent' / 'all' fetch the latest/up to limit.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskLimit = offlineCache?.maxTasks ?? OFFLINE_TASK_LIMIT;
const taskOptions: { limit: number; assignedTo?: number } = { limit: taskLimit };
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
for (const task of formTasks as any[]) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;
tasks.push(task);
Object.entries(fieldValues).forEach(([fieldId, value]) => {
taskFieldValues.push({ taskId: task.id, fieldId: Number(fieldId), value });
});
}
}
// Загружаем все доступные справочники организации
const dataTables = await storage.getDataTablesByOrganization(organizationId);
const dataTableRows: any[] = [];
for (const table of dataTables) {
const rows = await storage.getDataTableRows(table.id, organizationId);
dataTableRows.push(...rows);
}
const automations = await storage.getOfflineAutomations(organizationId);
const users = await storage.getUsersByOrganization(organizationId);
logAudit({
action: 'sync.initial',
userId,
organizationId,
details: { formCount: forms.length, taskCount: tasks.length },
});
res.json({
success: true,
forms,
tasks,
taskFieldValues,
dataTables,
dataTableRows,
automations: automations.map(a => ({
id: a.id,
name: a.name,
trigger: a.trigger,
triggerConfig: a.triggerConfig,
code: a.code,
runOffline: a.runOffline,
clientCompatible: a.clientCompatible,
})),
users,
});
} catch (err: any) {
console.error('[SYNC] initial error:', err);
res.status(500).json({ success: false, error: err.message || 'Ошибка начальной синхронизации' });
}
});
/**
* POST /api/sync/delta
* Дельта-синхронизация: изменения с момента `since`.
*/
router.post('/delta', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = deltaSyncSchema.parse(req.body);
const since = new Date(body.since);
let formIds = body.formIds;
if (!formIds || formIds.length === 0) {
const [globalForms, subscriptions] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId),
]);
const ids = new Set<number>();
globalForms.forEach(f => ids.add(f.id));
subscriptions.filter(s => s.isActive).forEach(s => ids.add(s.formId));
formIds = Array.from(ids);
}
const accessibleFormIds: number[] = [];
for (const formId of formIds) {
const canAccess = await storage.canUserAccessForm(userId, formId, organizationId, 'participate');
if (canAccess) accessibleFormIds.push(formId);
}
const changedForms: any[] = [];
const changedTasks: any[] = [];
const changedFieldValues: any[] = [];
const deletedTaskIds: number[] = [];
const changedUsers: any[] = [];
for (const formId of accessibleFormIds) {
const form = await storage.getForm(formId, organizationId);
if (form && (!form.updatedAt || new Date(form.updatedAt) >= since)) {
const [fields, statuses, tabs, transitions] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
]);
changedForms.push({ ...form, fields, statuses, tabs, transitions });
}
// Honor the form's offline-cache strategy for delta sync as well.
const offlineCache = (form as any)?.offlineCache as { enabled?: boolean; strategy?: string; maxTasks?: number } | null | undefined;
const taskOptions: { assignedTo?: number } = {};
if (offlineCache?.strategy === 'assigned') {
taskOptions.assignedTo = userId;
}
const { tasks: formTasks } = await storage.getTasksWithFieldsByFormOptimized(formId, organizationId, taskOptions);
const recentTasks = (formTasks as any[]).filter(t => !t.updatedAt || new Date(t.updatedAt) >= since);
for (const task of recentTasks) {
const fieldValues = task.fieldValues || {};
delete task.fieldValues;
changedTasks.push(task);
Object.entries(fieldValues).forEach(([fieldId, value]) => {
changedFieldValues.push({ taskId: task.id, fieldId: Number(fieldId), value });
});
}
// TODO: deleted tasks tracking через task_audit_log
}
const allUsers = await storage.getUsersByOrganization(organizationId);
for (const user of allUsers) {
if (!user.updatedAt || new Date(user.updatedAt) >= since) {
changedUsers.push(user);
}
}
res.json({
success: true,
since: since.toISOString(),
forms: changedForms,
tasks: changedTasks,
taskFieldValues: changedFieldValues,
deletedTaskIds,
users: changedUsers,
});
} catch (err: any) {
console.error('[SYNC] delta error:', err);
res.status(500).json({ success: false, error: err.message || 'Ошибка дельта-синхронизации' });
}
});
/**
* POST /api/sync/push
* Приём offline-мутаций, накопленных клиентом без интернета.
* В текущей версии возвращает accepted-список и conflicts.
*/
router.post('/push', async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const organizationId = req.organizationId!;
const body = pushSyncSchema.parse(req.body);
// LEGACY / NO-OP: the offline queue now replays mutations directly via
// authenticated fetch requests when the client comes back online.
// Keeping this endpoint for backward compatibility with older PWA builds.
const accepted: string[] = [];
const conflicts: Array<{ id: string; reason: string }> = [];
for (const mutation of body.mutations) {
const id = (mutation as any)?.id || crypto.randomUUID();
accepted.push(id);
}
logAudit({
action: 'sync.push',
userId,
organizationId,
details: { accepted: accepted.length, conflicts: conflicts.length },
});
res.json({ success: true, accepted, conflicts });
} catch (err: any) {
console.error('[SYNC] push error:', err);
res.status(500).json({ success: false, error: err.message || 'Ошибка приёма offline-мутаций' });
}
});
/**
* GET /api/sync/status
* Служебный статус синхронизации (online/offline, время сервера).
*/
router.get('/status', async (req: AuthenticatedRequest, res) => {
res.json({
success: true,
serverTime: new Date().toISOString(),
online: true,
});
});
app.use('/api/sync', router);
}