fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
359
server/routes/field-template-insert.routes.ts
Normal file
359
server/routes/field-template-insert.routes.ts
Normal 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: "Ошибка при создании/обновлении вкладки-таблицы" });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user