fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
157
server/services/contract-number.service.ts
Normal file
157
server/services/contract-number.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { db } from "../db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { tasks, taskFieldValues, contractNumberCounters, forms, formFields } from "@shared/schema";
|
||||
|
||||
export interface ContractNumberConfig {
|
||||
legalEntityFormId: number;
|
||||
prefixFieldCode: string;
|
||||
startNumber: number;
|
||||
sequencePadding: number;
|
||||
}
|
||||
|
||||
export interface GenerateContractNumberOpts {
|
||||
organizationId: number;
|
||||
formId: number;
|
||||
legalEntityTaskId: number;
|
||||
prefixFieldCode: string;
|
||||
startNumber: number;
|
||||
sequencePadding: number;
|
||||
}
|
||||
|
||||
export interface PreviewContractNumberOpts {
|
||||
organizationId: number;
|
||||
formId: number;
|
||||
legalEntityTaskId: number;
|
||||
config: ContractNumberConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить значение поля задачи по коду поля
|
||||
*/
|
||||
async function getTaskFieldValueByCode(
|
||||
taskId: number,
|
||||
fieldCode: string,
|
||||
organizationId: number
|
||||
): Promise<string | undefined> {
|
||||
// Найти задачу
|
||||
const [task] = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||||
.where(and(eq(tasks.id, taskId), eq(forms.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
|
||||
if (!task) return undefined;
|
||||
|
||||
// Найти поле по коду в форме задачи
|
||||
const [field] = await db
|
||||
.select()
|
||||
.from(formFields)
|
||||
.where(
|
||||
and(
|
||||
eq(formFields.formId, task.tasks.formId),
|
||||
eq(formFields.code, fieldCode)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!field) return undefined;
|
||||
|
||||
// Получить значение
|
||||
const [value] = await db
|
||||
.select()
|
||||
.from(taskFieldValues)
|
||||
.where(
|
||||
and(
|
||||
eq(taskFieldValues.taskId, taskId),
|
||||
eq(taskFieldValues.fieldId, field.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return value ? String(value.value) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сгенерировать номер договора атомарно (с резервацией счётчика)
|
||||
*/
|
||||
export async function generateContractNumber(
|
||||
opts: GenerateContractNumberOpts
|
||||
): Promise<string> {
|
||||
const { organizationId, formId, legalEntityTaskId, prefixFieldCode, startNumber, sequencePadding } = opts;
|
||||
|
||||
// Получить префикс из задачи-юрлица по коду поля
|
||||
const prefix = await getTaskFieldValueByCode(legalEntityTaskId, prefixFieldCode, organizationId);
|
||||
if (!prefix) {
|
||||
throw new Error(`Префикс не найден: поле ${prefixFieldCode} в задаче ${legalEntityTaskId}`);
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear() % 100;
|
||||
|
||||
// Атомарно получить/создать счётчик
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO contract_number_counters (organization_id, form_id, prefix, year, last_number)
|
||||
VALUES (${organizationId}, ${formId}, ${prefix}, ${year}, ${startNumber})
|
||||
ON CONFLICT (organization_id, form_id, prefix, year)
|
||||
DO UPDATE SET
|
||||
last_number = contract_number_counters.last_number + 1,
|
||||
updated_at = NOW()
|
||||
RETURNING last_number;
|
||||
`);
|
||||
|
||||
const nextNumber = Number((result.rows[0] as any)?.last_number ?? startNumber);
|
||||
|
||||
const yearStr = year.toString().padStart(2, '0');
|
||||
const seqStr = nextNumber.toString().padStart(sequencePadding ?? 3, '0');
|
||||
|
||||
return `${yearStr}${prefix}-${seqStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Предпросмотр номера (без резервации счётчика)
|
||||
*/
|
||||
export async function previewContractNumber(
|
||||
opts: PreviewContractNumberOpts
|
||||
): Promise<{ preview: string; year: number; prefix: string; nextNumber: number }> {
|
||||
const { organizationId, formId, legalEntityTaskId, config } = opts;
|
||||
|
||||
const prefix = await getTaskFieldValueByCode(
|
||||
legalEntityTaskId,
|
||||
config.prefixFieldCode,
|
||||
organizationId
|
||||
);
|
||||
|
||||
if (!prefix) {
|
||||
throw new Error(`Префикс не найден: поле ${config.prefixFieldCode} в задаче ${legalEntityTaskId}`);
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear() % 100;
|
||||
|
||||
// Заглянуть в существующий счётчик (без изменения)
|
||||
const [counter] = await db
|
||||
.select()
|
||||
.from(contractNumberCounters)
|
||||
.where(
|
||||
and(
|
||||
eq(contractNumberCounters.organizationId, organizationId),
|
||||
eq(contractNumberCounters.formId, formId),
|
||||
eq(contractNumberCounters.prefix, prefix),
|
||||
eq(contractNumberCounters.year, year)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const startNumber = config.startNumber ?? 1;
|
||||
const sequencePadding = config.sequencePadding ?? 3;
|
||||
const nextNumber = counter ? counter.lastNumber + 1 : startNumber;
|
||||
|
||||
const yearStr = year.toString().padStart(2, '0');
|
||||
const seqStr = nextNumber.toString().padStart(sequencePadding, '0');
|
||||
|
||||
return {
|
||||
preview: `${yearStr}${prefix}-${seqStr}`,
|
||||
year,
|
||||
prefix,
|
||||
nextNumber,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user