fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
508
server/storage/forms.storage.ts
Normal file
508
server/storage/forms.storage.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { forms, formTabs, formFields, formStatuses, statusTransitions, tasks, bookmarks, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type InsertForm, type InsertFormTab, type InsertFormField, type InsertFormStatus, type InsertStatusTransition } from "@shared/schema";
|
||||
import { db } from "../db";
|
||||
import { eq, and, desc, exists, sql } from "drizzle-orm";
|
||||
import crypto from "crypto";
|
||||
import { UserProfileStorage } from "./user-profile.storage";
|
||||
export class FormsStorage extends UserProfileStorage {
|
||||
// Forms
|
||||
async getForm(id: number, organizationId: number): Promise<Form | undefined> {
|
||||
const [form] = await db
|
||||
.select()
|
||||
.from(forms)
|
||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)));
|
||||
return form || undefined;
|
||||
}
|
||||
|
||||
async getFormsByOrganization(organizationId: number): Promise<Form[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(forms)
|
||||
.where(eq(forms.organizationId, organizationId))
|
||||
.orderBy(desc(forms.createdAt));
|
||||
}
|
||||
|
||||
async createForm(insertForm: InsertForm): Promise<Form> {
|
||||
const [form] = await db
|
||||
.insert(forms)
|
||||
.values(insertForm)
|
||||
.returning();
|
||||
return form;
|
||||
}
|
||||
|
||||
async updateForm(id: number, organizationId: number, updates: Partial<Form>): Promise<Form> {
|
||||
const [form] = await db
|
||||
.update(forms)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)))
|
||||
.returning();
|
||||
return form;
|
||||
}
|
||||
|
||||
async deleteForm(id: number, organizationId: number): Promise<void> {
|
||||
// Cleanup RAG embeddings for this form and all its tasks/messages
|
||||
const { deleteRagEmbeddingsByForm } = await import('../services/embedding.service.js');
|
||||
await deleteRagEmbeddingsByForm(organizationId, id);
|
||||
|
||||
// Удаляем закладки, ссылающиеся на эту форму
|
||||
await db
|
||||
.delete(bookmarks)
|
||||
.where(eq(bookmarks.formId, id));
|
||||
|
||||
// Удаляем все задачи формы (каскадно удалятся их сообщения, значения полей и т.д.)
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(eq(tasks.formId, id));
|
||||
|
||||
// Теперь удаляем саму форму
|
||||
await db
|
||||
.delete(forms)
|
||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)));
|
||||
}
|
||||
|
||||
async getTasksCountByForm(formId: number): Promise<{ total: number; active: number; completed: number }> {
|
||||
const result = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)::int`,
|
||||
active: sql<number>`count(*) filter (where is_completed = false)::int`,
|
||||
completed: sql<number>`count(*) filter (where is_completed = true)::int`
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.formId, formId));
|
||||
return {
|
||||
total: result[0]?.total || 0,
|
||||
active: result[0]?.active || 0,
|
||||
completed: result[0]?.completed || 0
|
||||
};
|
||||
}
|
||||
|
||||
// Form Tabs
|
||||
async getFormTabs(formId: number, organizationId: number): Promise<FormTab[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(formTabs)
|
||||
.innerJoin(forms, eq(formTabs.formId, forms.id))
|
||||
.where(and(eq(formTabs.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(formTabs.displayOrder)
|
||||
.then(results => results.map(r => r.form_tabs));
|
||||
}
|
||||
|
||||
async getFormTabsByCreationOrder(formId: number, organizationId: number): Promise<FormTab[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(formTabs)
|
||||
.innerJoin(forms, eq(formTabs.formId, forms.id))
|
||||
.where(and(eq(formTabs.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(formTabs.id)
|
||||
.then(results => results.map(r => r.form_tabs));
|
||||
}
|
||||
|
||||
async getFormTab(id: number, formId: number, organizationId: number): Promise<FormTab | undefined> {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(formTabs)
|
||||
.innerJoin(forms, eq(formTabs.formId, forms.id))
|
||||
.where(and(eq(formTabs.id, id), eq(formTabs.formId, formId), eq(forms.organizationId, organizationId)));
|
||||
return results[0]?.form_tabs;
|
||||
}
|
||||
|
||||
async createFormTab(insertTab: InsertFormTab): Promise<FormTab> {
|
||||
const [tab] = await db
|
||||
.insert(formTabs)
|
||||
.values(insertTab)
|
||||
.returning();
|
||||
return tab;
|
||||
}
|
||||
|
||||
async updateFormTab(id: number, formId: number, organizationId: number, updates: Partial<FormTab>): Promise<FormTab> {
|
||||
const [tab] = await db
|
||||
.update(formTabs)
|
||||
.set(updates)
|
||||
.where(and(
|
||||
eq(formTabs.id, id),
|
||||
eq(formTabs.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
return tab;
|
||||
}
|
||||
|
||||
async deleteFormTab(id: number, formId: number, organizationId: number): Promise<void> {
|
||||
await db
|
||||
.delete(formTabs)
|
||||
.where(and(
|
||||
eq(formTabs.id, id),
|
||||
eq(formTabs.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
));
|
||||
}
|
||||
|
||||
async bulkSaveFormTabs(
|
||||
formId: number,
|
||||
organizationId: number,
|
||||
tabs: Array<{ id?: number; name: string; code: string; type: string; position?: number; displayOrder?: number; tableColumns?: unknown }>
|
||||
): Promise<FormTab[]> {
|
||||
return await db.transaction(async (tx) => {
|
||||
const existingTabs = await tx
|
||||
.select()
|
||||
.from(formTabs)
|
||||
.where(eq(formTabs.formId, formId));
|
||||
|
||||
const incomingIds = tabs.filter(t => t.id && t.id > 0).map(t => t.id!);
|
||||
const tabsToDelete = existingTabs.filter(e => !incomingIds.includes(e.id));
|
||||
|
||||
for (const tab of tabsToDelete) {
|
||||
await tx.delete(formTabs).where(and(eq(formTabs.id, tab.id), eq(formTabs.formId, formId)));
|
||||
}
|
||||
|
||||
const savedTabs: FormTab[] = [];
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i];
|
||||
const tabData = {
|
||||
formId,
|
||||
name: tab.name || '',
|
||||
code: tab.code || `tab_${i + 1}`,
|
||||
type: tab.type || 'fields',
|
||||
position: tab.position ?? i,
|
||||
displayOrder: tab.displayOrder ?? i,
|
||||
tableColumns: tab.tableColumns || null,
|
||||
};
|
||||
|
||||
if (tab.id && tab.id > 0) {
|
||||
const [updated] = await tx
|
||||
.update(formTabs)
|
||||
.set(tabData)
|
||||
.where(and(eq(formTabs.id, tab.id), eq(formTabs.formId, formId)))
|
||||
.returning();
|
||||
if (updated) savedTabs.push(updated);
|
||||
} else {
|
||||
const [created] = await tx
|
||||
.insert(formTabs)
|
||||
.values(tabData)
|
||||
.returning();
|
||||
savedTabs.push(created);
|
||||
}
|
||||
}
|
||||
return savedTabs;
|
||||
});
|
||||
}
|
||||
|
||||
// Form Fields
|
||||
async getFormFields(formId: number, organizationId: number): Promise<FormField[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(formFields)
|
||||
.innerJoin(forms, eq(formFields.formId, forms.id))
|
||||
.where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(formFields.displayOrder)
|
||||
.then(results => results.map(r => r.form_fields));
|
||||
}
|
||||
|
||||
async getFormFieldsByCreationOrder(formId: number, organizationId: number): Promise<FormField[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(formFields)
|
||||
.innerJoin(forms, eq(formFields.formId, forms.id))
|
||||
.where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(formFields.id)
|
||||
.then(results => results.map(r => r.form_fields));
|
||||
}
|
||||
|
||||
async getFormFieldById(id: number, organizationId: number): Promise<FormField | undefined> {
|
||||
const [field] = await db
|
||||
.select({ field: formFields })
|
||||
.from(formFields)
|
||||
.innerJoin(forms, eq(formFields.formId, forms.id))
|
||||
.where(and(eq(formFields.id, id), eq(forms.organizationId, organizationId)));
|
||||
return field?.field || undefined;
|
||||
}
|
||||
|
||||
async createFormField(insertField: InsertFormField): Promise<FormField> {
|
||||
const [field] = await db
|
||||
.insert(formFields)
|
||||
.values(insertField)
|
||||
.returning();
|
||||
return field;
|
||||
}
|
||||
|
||||
async updateFormField(id: number, formId: number, organizationId: number, updates: Partial<FormField>): Promise<FormField> {
|
||||
const [field] = await db
|
||||
.update(formFields)
|
||||
.set(updates)
|
||||
.where(and(
|
||||
eq(formFields.id, id),
|
||||
eq(formFields.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
return field;
|
||||
}
|
||||
|
||||
async deleteFormField(id: number, formId: number, organizationId: number): Promise<void> {
|
||||
await db
|
||||
.delete(formFields)
|
||||
.where(and(
|
||||
eq(formFields.id, id),
|
||||
eq(formFields.formId, formId),
|
||||
// Убедимся что form принадлежит организации через subquery
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
));
|
||||
}
|
||||
|
||||
async bulkSaveFormFields(
|
||||
formId: number,
|
||||
organizationId: number,
|
||||
fieldIdsToDelete: number[],
|
||||
fields: Array<{
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
isRequired?: boolean;
|
||||
placeholder?: string | null;
|
||||
defaultValue?: string | null;
|
||||
validationRules?: unknown;
|
||||
requiredRules?: unknown;
|
||||
immutableRules?: unknown;
|
||||
visibilityRules?: unknown;
|
||||
options?: unknown;
|
||||
position?: number | null;
|
||||
displayOrder?: number | null;
|
||||
tabId?: number | null;
|
||||
linkedFormId?: number | null;
|
||||
taskRelationType?: string | null;
|
||||
companyAutofill?: unknown;
|
||||
maxFileCount?: number | null;
|
||||
maxFileSizeMB?: number | null;
|
||||
autoProlongation?: boolean | null;
|
||||
autoProlongationLabel?: string | null;
|
||||
linkToUserProfile?: boolean | null;
|
||||
allowMultiple?: boolean | null;
|
||||
ganttRole?: string | null;
|
||||
}>
|
||||
): Promise<FormField[]> {
|
||||
return await db.transaction(async (tx) => {
|
||||
for (const fieldId of fieldIdsToDelete) {
|
||||
await tx
|
||||
.delete(formFields)
|
||||
.where(and(eq(formFields.id, fieldId), eq(formFields.formId, formId)));
|
||||
}
|
||||
|
||||
const resultFields: FormField[] = [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
const fieldData = {
|
||||
formId,
|
||||
name: field.name,
|
||||
code: field.code,
|
||||
type: field.type,
|
||||
isRequired: field.isRequired || false,
|
||||
placeholder: field.placeholder || null,
|
||||
defaultValue: field.defaultValue || null,
|
||||
validationRules: field.validationRules || null,
|
||||
requiredRules: field.requiredRules || null,
|
||||
immutableRules: field.immutableRules || null,
|
||||
visibilityRules: field.visibilityRules || null,
|
||||
options: field.options || null,
|
||||
position: field.position ?? i,
|
||||
displayOrder: field.displayOrder ?? i,
|
||||
tabId: field.tabId || null,
|
||||
linkedFormId: field.linkedFormId || null,
|
||||
taskRelationType: field.taskRelationType || null,
|
||||
companyAutofill: field.companyAutofill || null,
|
||||
maxFileCount: field.maxFileCount != null && field.maxFileCount > 0 ? field.maxFileCount : null,
|
||||
maxFileSizeMB: field.maxFileSizeMB != null && field.maxFileSizeMB > 0 ? field.maxFileSizeMB : null,
|
||||
autoProlongation: field.autoProlongation || false,
|
||||
autoProlongationLabel: field.autoProlongationLabel || null,
|
||||
linkToUserProfile: field.linkToUserProfile || false,
|
||||
allowMultiple: field.allowMultiple || false,
|
||||
ganttRole: field.ganttRole || null,
|
||||
};
|
||||
|
||||
if (field.id && field.id > 0) {
|
||||
const [updated] = await tx
|
||||
.update(formFields)
|
||||
.set(fieldData)
|
||||
.where(and(
|
||||
eq(formFields.id, field.id),
|
||||
eq(formFields.formId, formId),
|
||||
exists(tx.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
if (updated) resultFields.push(updated);
|
||||
} else {
|
||||
const [created] = await tx
|
||||
.insert(formFields)
|
||||
.values(fieldData)
|
||||
.returning();
|
||||
resultFields.push(created);
|
||||
}
|
||||
}
|
||||
return resultFields;
|
||||
});
|
||||
}
|
||||
|
||||
// Form Statuses
|
||||
async getFormStatuses(formId: number, organizationId: number): Promise<FormStatus[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(formStatuses)
|
||||
.innerJoin(forms, eq(formStatuses.formId, forms.id))
|
||||
.where(and(eq(formStatuses.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(formStatuses.position)
|
||||
.then(results => results.map(r => r.form_statuses));
|
||||
}
|
||||
|
||||
async createFormStatus(insertStatus: InsertFormStatus): Promise<FormStatus> {
|
||||
const [status] = await db
|
||||
.insert(formStatuses)
|
||||
.values(insertStatus)
|
||||
.returning();
|
||||
return status;
|
||||
}
|
||||
|
||||
async updateFormStatus(id: number, formId: number, organizationId: number, updates: Partial<FormStatus>): Promise<FormStatus> {
|
||||
const [status] = await db
|
||||
.update(formStatuses)
|
||||
.set(updates)
|
||||
.where(and(
|
||||
eq(formStatuses.id, id),
|
||||
eq(formStatuses.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
return status;
|
||||
}
|
||||
|
||||
async deleteFormStatus(id: number, formId: number, organizationId: number): Promise<void> {
|
||||
await db
|
||||
.delete(formStatuses)
|
||||
.where(and(
|
||||
eq(formStatuses.id, id),
|
||||
eq(formStatuses.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
));
|
||||
}
|
||||
|
||||
async bulkSaveFormStatuses(
|
||||
formId: number,
|
||||
organizationId: number,
|
||||
statusIdsToDelete: number[],
|
||||
statuses: Array<{ id?: number; name: string; color?: string; isInitial?: boolean; isFinal?: boolean; entryConditions?: { fieldCode: string; value: string }[] | null }>
|
||||
): Promise<FormStatus[]> {
|
||||
return await db.transaction(async (tx) => {
|
||||
for (const statusId of statusIdsToDelete) {
|
||||
await tx
|
||||
.delete(formStatuses)
|
||||
.where(and(eq(formStatuses.id, statusId), eq(formStatuses.formId, formId)));
|
||||
}
|
||||
|
||||
const resultStatuses: FormStatus[] = [];
|
||||
for (let i = 0; i < statuses.length; i++) {
|
||||
const status = statuses[i];
|
||||
const statusData: InsertFormStatus = {
|
||||
formId,
|
||||
name: status.name,
|
||||
color: status.color || '#e5e7eb',
|
||||
position: i,
|
||||
isInitial: status.isInitial || false,
|
||||
isFinal: status.isFinal || false,
|
||||
entryConditions: status.entryConditions ?? null,
|
||||
};
|
||||
|
||||
if (status.id && status.id > 0) {
|
||||
const [updated] = await tx
|
||||
.update(formStatuses)
|
||||
.set(statusData)
|
||||
.where(and(
|
||||
eq(formStatuses.id, status.id),
|
||||
eq(formStatuses.formId, formId),
|
||||
exists(tx.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
if (updated) resultStatuses.push(updated);
|
||||
} else {
|
||||
const [created] = await tx
|
||||
.insert(formStatuses)
|
||||
.values(statusData)
|
||||
.returning();
|
||||
resultStatuses.push(created);
|
||||
}
|
||||
}
|
||||
return resultStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
// Status Transitions
|
||||
async getStatusTransitions(formId: number, organizationId: number): Promise<StatusTransition[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(statusTransitions)
|
||||
.innerJoin(forms, eq(statusTransitions.formId, forms.id))
|
||||
.where(and(eq(statusTransitions.formId, formId), eq(forms.organizationId, organizationId)))
|
||||
.orderBy(statusTransitions.id)
|
||||
.then(results => results.map(r => r.status_transitions));
|
||||
}
|
||||
|
||||
async createStatusTransition(insertTransition: InsertStatusTransition): Promise<StatusTransition> {
|
||||
const [transition] = await db
|
||||
.insert(statusTransitions)
|
||||
.values(insertTransition)
|
||||
.returning();
|
||||
return transition;
|
||||
}
|
||||
|
||||
async updateStatusTransition(id: number, formId: number, organizationId: number, updates: Partial<StatusTransition>): Promise<StatusTransition> {
|
||||
const [transition] = await db
|
||||
.update(statusTransitions)
|
||||
.set(updates)
|
||||
.where(and(
|
||||
eq(statusTransitions.id, id),
|
||||
eq(statusTransitions.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
))
|
||||
.returning();
|
||||
return transition;
|
||||
}
|
||||
|
||||
async deleteStatusTransition(id: number, formId: number, organizationId: number): Promise<void> {
|
||||
await db
|
||||
.delete(statusTransitions)
|
||||
.where(and(
|
||||
eq(statusTransitions.id, id),
|
||||
eq(statusTransitions.formId, formId),
|
||||
exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId))))
|
||||
));
|
||||
}
|
||||
|
||||
// Offline cache configuration
|
||||
async getFormsWithOfflineCache(organizationId: number): Promise<Form[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(forms)
|
||||
.where(and(
|
||||
eq(forms.organizationId, organizationId),
|
||||
sql`${forms.offlineCache} IS NOT NULL`,
|
||||
sql`(${forms.offlineCache}->>'enabled')::boolean = true`
|
||||
))
|
||||
.orderBy(desc(forms.createdAt));
|
||||
}
|
||||
|
||||
async getOfflineCacheConfig(id: number, organizationId: number): Promise<Form['offlineCache']> {
|
||||
const [form] = await db
|
||||
.select({ offlineCache: forms.offlineCache })
|
||||
.from(forms)
|
||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)));
|
||||
return form?.offlineCache ?? null;
|
||||
}
|
||||
|
||||
async updateFormOfflineCache(id: number, organizationId: number, config: Form['offlineCache']): Promise<Form> {
|
||||
const [form] = await db
|
||||
.update(forms)
|
||||
.set({ offlineCache: config, updatedAt: new Date() })
|
||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)))
|
||||
.returning();
|
||||
if (!form) throw new Error("Форма не найдена");
|
||||
return form;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user