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,359 @@
import type { Express } from "express";
import { storage } from "../storage";
import { authenticateToken, requireFormAdminOrPermission, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import type { FormField, FormTab } from "@shared/schema";
type ColDef = { id: string; name: string; type: string; width?: number };
// DaData sub-field definitions for company autofill
const DADATA_SUBFIELDS: Array<{ key: string; label: string; suffix: string }> = [
{ key: "name.full_with_opf", label: "Полное наименование", suffix: "full_name" },
{ key: "name.short_with_opf", label: "Краткое наименование", suffix: "short_name" },
{ key: "inn", label: "ИНН", suffix: "inn" },
{ key: "kpp", label: "КПП", suffix: "kpp" },
{ key: "ogrn", label: "ОГРН", suffix: "ogrn" },
{ key: "management.name", label: "ФИО руководителя", suffix: "director_name" },
{ key: "management.post", label: "Должность руководителя", suffix: "director_post" },
];
/**
* Shift positions of existing fields that are >= targetPosition upward by 1,
* so the new field can be inserted at targetPosition without collisions.
*/
async function shiftFieldPositions(
existingFields: FormField[],
formId: number,
orgId: number,
targetPosition: number
): Promise<void> {
const toShift = existingFields
.filter((f) => f.position >= targetPosition)
.sort((a, b) => b.position - a.position);
for (const f of toShift) {
await storage.updateFormField(f.id, formId, orgId, { position: f.position + 1 });
}
}
export function registerFieldTemplateInsertRoutes(app: Express): void {
/**
* POST /api/forms/:formId/fields/from-template
* Вставляет шаблон поля в форму как обычное поле.
* Body: { fieldTemplateId: number, position?: number, tabId?: number }
* Код поля = ft_{fieldTemplateId}. Если уже есть — возвращает существующее.
*/
app.post(
"/api/forms/:formId/fields/from-template",
authenticateToken,
requireFormAdminOrPermission('forms.manage', 'formId'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.formId);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: "Неверный ID формы" });
}
const { fieldTemplateId, position, tabId } = req.body as {
fieldTemplateId?: number;
position?: number;
tabId?: number;
};
if (!fieldTemplateId || typeof fieldTemplateId !== "number") {
return res.status(400).json({ success: false, error: "fieldTemplateId обязателен" });
}
const orgId = req.organizationId!;
const [form, fieldTemplate] = await Promise.all([
storage.getForm(formId, orgId),
storage.getFieldTemplate(fieldTemplateId, orgId),
]);
if (!form) {
return res.status(404).json({ success: false, error: "Форма не найдена" });
}
if (!fieldTemplate) {
return res.status(404).json({ success: false, error: "Шаблон поля не найден" });
}
const fieldCode = `ft_${fieldTemplate.code}`;
const existingFields = await storage.getFormFields(formId, orgId);
const existing = existingFields.find((f) => f.code === fieldCode);
if (existing) {
return res.json({ success: true, field: existing, alreadyExists: true });
}
const maxPos =
existingFields.length > 0 ? Math.max(...existingFields.map((f) => f.position)) : -1;
const targetPosition =
typeof position === "number" ? Math.max(0, position) : maxPos + 1;
if (typeof position === "number") {
await shiftFieldPositions(existingFields, formId, orgId, targetPosition);
}
let resolvedTabId: number | null = null;
// Track requested tabId for client-side patching (negative IDs = unsaved tabs)
const requestedTabId = typeof tabId === "number" ? tabId : null;
if (typeof tabId === "number" && tabId >= 0) {
const tab = await storage.getFormTab(tabId, formId, orgId);
if (!tab) {
return res.status(400).json({ success: false, error: "Указанная вкладка не принадлежит этой форме" });
}
resolvedTabId = tabId;
}
// Build companyAutofill for form field (if company type with autofill configured)
let formCompanyAutofill: Record<string, string> | null = null;
const createdSubFieldCodes: string[] = [];
if (fieldTemplate.type === "company" && fieldTemplate.companyAutofill) {
const templateAutofill = fieldTemplate.companyAutofill as Record<string, string>;
formCompanyAutofill = {};
for (const [dadataKey, suffix] of Object.entries(templateAutofill)) {
const meta = DADATA_SUBFIELDS.find((s) => s.key === dadataKey);
if (!meta) continue;
const subCode = `ft_${fieldTemplate.code}_${suffix}`;
// Create sub-field if it doesn't exist yet
const subExists = existingFields.find((f) => f.code === subCode);
if (!subExists) {
await storage.createFormField({
formId,
tabId: resolvedTabId,
name: meta.label,
code: subCode,
type: "text",
isRequired: false,
placeholder: null,
defaultValue: null,
validationRules: null,
options: null,
position: targetPosition + 1 + createdSubFieldCodes.length,
linkedFormId: null,
buttonActionType: null,
buttonUrl: null,
buttonFormId: null,
companyAutofill: null,
taskRelationType: null,
maxFileCount: null,
maxFileSizeMB: null,
} as Parameters<typeof storage.createFormField>[0]);
createdSubFieldCodes.push(subCode);
}
formCompanyAutofill[dadataKey] = subCode;
}
}
const newField = await storage.createFormField({
formId,
tabId: resolvedTabId,
name: fieldTemplate.name,
code: fieldCode,
type: fieldTemplate.type,
isRequired: fieldTemplate.isRequired ?? false,
placeholder: fieldTemplate.placeholder ?? null,
defaultValue: fieldTemplate.defaultValue ?? null,
validationRules: fieldTemplate.validationRules ?? null,
options: fieldTemplate.options ?? null,
position: targetPosition,
linkedFormId: null,
buttonActionType: null,
buttonUrl: null,
buttonFormId: null,
companyAutofill: formCompanyAutofill,
taskRelationType: null,
maxFileCount: null,
maxFileSizeMB: null,
} as Parameters<typeof storage.createFormField>[0]);
// Fetch all newly created sub-fields from DB to return full objects
const updatedFields = await storage.getFormFields(formId, orgId);
const createdSubFields = updatedFields.filter((f) => createdSubFieldCodes.includes(f.code));
return res.status(201).json({ success: true, field: newField, alreadyExists: false, createdSubFields, requestedTabId });
} catch (error) {
console.error("add field template to form error:", error);
return res.status(500).json({ success: false, error: "Ошибка при вставке шаблона поля в форму" });
}
}
);
/**
* POST /api/data-tables/:tableId/fields/from-template
* Вставляет шаблон поля в справочник (data table) как колонку.
* Body: { fieldTemplateId: number }
* Код колонки = ft_{fieldTemplate.code}.
*/
app.post(
"/api/data-tables/:tableId/fields/from-template",
authenticateToken,
requirePermission('forms.manage'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const tableId = parseInt(req.params.tableId);
if (isNaN(tableId)) {
return res.status(400).json({ success: false, error: "Неверный ID справочника" });
}
const { fieldTemplateId } = req.body as { fieldTemplateId?: number };
if (!fieldTemplateId || typeof fieldTemplateId !== "number") {
return res.status(400).json({ success: false, error: "fieldTemplateId обязателен" });
}
const orgId = req.organizationId!;
const [table, fieldTemplate] = await Promise.all([
storage.getDataTable(tableId, orgId),
storage.getFieldTemplate(fieldTemplateId, orgId),
]);
if (!table) {
return res.status(404).json({ success: false, error: "Справочник не найден" });
}
if (!fieldTemplate) {
return res.status(404).json({ success: false, error: "Шаблон поля не найден" });
}
const columnCode = `ft_${fieldTemplate.code}`;
const existingColumns: Array<{ name: string; type: string; options?: string[]; isRequired?: boolean }> =
Array.isArray(table.columns) ? table.columns : [];
const existing = existingColumns.find((c) => c.name === columnCode || c.name === fieldTemplate.name);
if (existing) {
return res.json({ success: true, column: existing, alreadyExists: true });
}
const newColumn = {
name: fieldTemplate.name,
type: fieldTemplate.type,
options: Array.isArray(fieldTemplate.options) ? fieldTemplate.options : undefined,
isRequired: fieldTemplate.isRequired ?? false,
};
const updatedColumns = [...existingColumns, newColumn];
await storage.updateDataTable(tableId, orgId, { columns: updatedColumns });
return res.status(201).json({ success: true, column: newColumn, alreadyExists: false });
} catch (error) {
console.error("add field template to data table error:", error);
return res.status(500).json({ success: false, error: "Ошибка при вставке шаблона поля в справочник" });
}
}
);
/**
* POST /api/forms/:formId/tabs/table-from-templates
* Создаёт вкладку «Простая таблица» с колонками из шаблонов полей,
* или дополняет существующую вкладку недостающими колонками.
* Body: { tabName: string, fieldTemplateIds: number[] }
*/
app.post(
"/api/forms/:formId/tabs/table-from-templates",
authenticateToken,
requireFormAdminOrPermission('forms.manage', 'formId'),
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.formId);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: "Неверный ID формы" });
}
const { tabName, fieldTemplateIds } = req.body as {
tabName?: string;
fieldTemplateIds?: number[];
};
if (!tabName || typeof tabName !== "string" || !tabName.trim()) {
return res.status(400).json({ success: false, error: "tabName обязателен" });
}
if (!Array.isArray(fieldTemplateIds) || fieldTemplateIds.length === 0) {
return res.status(400).json({ success: false, error: "fieldTemplateIds должен быть непустым массивом" });
}
const orgId = req.organizationId!;
const [form, existingTabs] = await Promise.all([
storage.getForm(formId, orgId),
storage.getFormTabs(formId, orgId),
]);
if (!form) {
return res.status(404).json({ success: false, error: "Форма не найдена" });
}
const uniqueFieldTemplateIds = [...new Set(fieldTemplateIds.map(Number).filter(Number.isFinite))];
const fieldTemplatesList = await Promise.all(
uniqueFieldTemplateIds.map((id) => storage.getFieldTemplate(id, orgId))
);
const validFieldTemplates = fieldTemplatesList.filter(Boolean) as NonNullable<Awaited<ReturnType<typeof storage.getFieldTemplate>>>[];
if (validFieldTemplates.length === 0) {
return res.status(404).json({ success: false, error: "Ни один из указанных шаблонов полей не найден" });
}
const trimmedName = tabName.trim();
let tab = existingTabs.find(
(t) => t.name.toLowerCase() === trimmedName.toLowerCase() && t.type === "table"
);
let isNew = false;
if (!tab) {
const tabCode = `gft_${trimmedName
.toLowerCase()
.replace(/[^a-z0-9а-яё]/gi, "_")
.replace(/_+/g, "_")
.slice(0, 80)}_${Date.now()}`;
const maxPosition =
existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position)) + 1 : 0;
tab = await storage.createFormTab({
formId,
name: trimmedName,
code: tabCode,
type: "table",
position: maxPosition,
tableColumns: [] as ColDef[],
} as Parameters<typeof storage.createFormTab>[0]);
isNew = true;
}
const currentColumns: ColDef[] = Array.isArray(tab.tableColumns)
? (tab.tableColumns as ColDef[])
: [];
const existingColIds = new Set(currentColumns.map((c) => c.id));
const newColumns: ColDef[] = validFieldTemplates
.filter((ft) => !existingColIds.has(`ft_${ft.id}`))
.map((ft) => ({
id: `ft_${ft.id}`,
name: ft.name,
type: ft.type === "number" ? "number" : ft.type === "date" ? "date" : "text",
}));
let updatedTab: FormTab = tab;
if (newColumns.length > 0) {
const mergedColumns = [...currentColumns, ...newColumns];
updatedTab = await storage.updateFormTab(tab.id, formId, orgId, {
tableColumns: mergedColumns,
});
}
return res.status(isNew ? 201 : 200).json({
success: true,
tab: updatedTab,
isNew,
addedColumns: newColumns.length,
skippedColumns: validFieldTemplates.length - newColumns.length,
});
} catch (error) {
console.error("table-from-templates error:", error);
return res.status(500).json({ success: false, error: "Ошибка при создании/обновлении вкладки-таблицы" });
}
}
);
}