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

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

158 lines
4.6 KiB
TypeScript
Raw 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 { 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,
};
}