Files
iistwin/server/routes/field-template-insert.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

360 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "Ошибка при создании/обновлении вкладки-таблицы" });
}
}
);
}