fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,369 @@
import { query } from "./db-client";
import { getTableConfig } from "./table-config";
import { storage } from "../storage";
import { sql } from "drizzle-orm";
import { db } from "../db";
import { formatFieldValueForTitle, buildTableRowMap } from "../routes/shared";
import { findCustomerByInn } from "./sync-utils";
const LEGAL_ENTITY_FORM_ID = 7;
const CUSTOMER_FORM_ID = 8;
const OMEGA_PROM_NAME = "ОМЕГАПРОМ";
export interface ParsedContract {
raw: string;
number: string;
date: string; // YYYY-MM-DD
inn: string;
}
export interface ContractSyncItem {
raw: string;
number: string;
date: string;
inn: string;
customerTaskId?: number;
}
export interface SyncApplyContract {
raw: string;
number: string;
date: string;
inn: string;
customerTaskId: number;
}
function parseDate(dateStr: string): string | null {
const match = dateStr.match(/^(\d{2})\.(\d{2})\.(\d{2,4})$/);
if (!match) return null;
const [, day, month, yearStr] = match;
let year = parseInt(yearStr, 10);
if (yearStr.length === 2) {
year = year >= 50 ? 1900 + year : 2000 + year;
}
return `${year}-${month}-${day}`;
}
export function parseContractString(raw: string): { number: string; date: string } | null {
const trimmed = raw.trim();
if (!trimmed) return null;
// Extract number: everything after № (with optional space) up to " от "
const numberMatch = trimmed.match(/№\s*([^\s].*?)\s+от\s/);
if (!numberMatch) return null;
const number = numberMatch[1].trim();
// Extract date: DD.MM.YY or DD.MM.YYYY
const dateMatch = trimmed.match(/(\d{2})\.(\d{2})\.(\d{2,4})/);
if (!dateMatch) return null;
const date = parseDate(`${dateMatch[1]}.${dateMatch[2]}.${dateMatch[3]}`);
if (!date) return null;
return { number, date };
}
export async function getUniqueFinanceContracts(): Promise<{
contracts: ParsedContract[];
skipped: number;
}> {
const config = getTableConfig();
const result = await query(
`SELECT DISTINCT "dogovorzakazchika" as raw, "innzakazchika" as inn FROM "${config.incomeTable}" WHERE "dogovorzakazchika" IS NOT NULL AND "dogovorzakazchika" <> ''`
);
const contracts: ParsedContract[] = [];
let skipped = 0;
const seen = new Set<string>();
for (const row of result.rows) {
const raw = String(row.raw || "").trim();
const parsed = parseContractString(raw);
if (!parsed) {
skipped++;
continue;
}
const inn = row.inn ? String(row.inn).trim() : "";
if (!inn || inn === "0") {
skipped++;
continue;
}
const key = `${parsed.number}:${parsed.date}:${inn}`;
if (seen.has(key)) continue;
seen.add(key);
contracts.push({ raw, number: parsed.number, date: parsed.date, inn });
}
contracts.sort((a, b) => a.raw.localeCompare(b.raw));
return { contracts, skipped };
}
export async function findOmegaPromTaskId(organizationId: number): Promise<number | null> {
const result = await db.execute(sql`
SELECT id FROM tasks
WHERE form_id = ${LEGAL_ENTITY_FORM_ID}
AND organization_id = ${organizationId}
AND title ILIKE ${"%" + OMEGA_PROM_NAME + "%"}
ORDER BY id
LIMIT 1
`);
const rows = result.rows as Array<{ id: number }>;
return rows[0]?.id ?? null;
}
export async function findExistingContracts(
formId: number,
organizationId: number
): Promise<Set<string>> {
const formFields = await storage.getFormFields(formId, organizationId);
const contractField = formFields.find((f) => f.type === "contract-number");
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
if (!contractField || !customerField) {
return new Set();
}
const result = await db.execute(sql`
SELECT t.id
FROM tasks t
JOIN task_field_values v1 ON t.id = v1.task_id AND t.form_id = v1.form_id
JOIN task_field_values v2 ON t.id = v2.task_id AND t.form_id = v2.form_id
WHERE t.form_id = ${formId}
AND t.organization_id = ${organizationId}
AND v1.field_id = ${contractField.id}
AND v1.value IS NOT NULL
AND v2.field_id = ${customerField.id}
AND v2.value IS NOT NULL
`);
const existing = new Set<string>();
for (const row of result.rows as Array<{ id: number }>) {
// Load field values for this task
const values = await storage.getTaskFieldValues(row.id, organizationId);
const contractValue = values.find((v) => v.fieldId === contractField.id)?.value;
const customerValue = values.find((v) => v.fieldId === customerField.id)?.value;
if (contractValue && customerValue) {
try {
const parsed = typeof contractValue === "string" ? JSON.parse(contractValue) : contractValue;
const number = parsed?.value || "";
const customerId = String(customerValue).trim();
if (number && customerId) {
existing.add(`${number}:${customerId}`);
}
} catch {
// ignore parse errors
}
}
}
return existing;
}
export async function previewContractSync(
formId: number,
organizationId: number
): Promise<{
auto: ContractSyncItem[];
ambiguous: Array<ContractSyncItem & { reason: string }>;
existing: ContractSyncItem[];
skipped: number;
}> {
const { contracts, skipped } = await getUniqueFinanceContracts();
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
const existingContracts = await findExistingContracts(formId, organizationId);
const auto: ContractSyncItem[] = [];
const ambiguous: Array<ContractSyncItem & { reason: string }> = [];
const existing: ContractSyncItem[] = [];
for (const contract of contracts) {
const customerTaskId = await findCustomerByInn(contract.inn, organizationId);
const existingKey = `${contract.number}:${customerTaskId || ""}`;
if (existingContracts.has(existingKey)) {
existing.push(contract);
continue;
}
if (!omegaPromTaskId) {
ambiguous.push({ ...contract, reason: "Не найдено юр.лицо ОМЕГАПРОМ" });
continue;
}
if (!customerTaskId) {
ambiguous.push({ ...contract, reason: `Не найден заказчик по ИНН ${contract.inn}` });
continue;
}
auto.push({ ...contract, customerTaskId });
}
return { auto, ambiguous, existing, skipped };
}
export async function createContractTask(
formId: number,
organizationId: number,
createdBy: number,
contract: SyncApplyContract,
omegaPromTaskId: number
): Promise<{ taskId: number; title: string }> {
const formFields = await storage.getFormFields(formId, organizationId);
const contractField = formFields.find((f) => f.type === "contract-number");
const dateField = formFields.find((f) => f.type === "date");
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
if (!contractField) {
throw new Error("В форме не найдено поле типа contract-number");
}
if (!dateField) {
throw new Error("В форме не найдено поле типа date");
}
if (!customerField) {
throw new Error("В форме не найдено поле типа task, связанное с формой Заказчик");
}
const form = await storage.getForm(formId, organizationId);
if (!form) {
throw new Error("Форма не найдена");
}
let statuses = await storage.getFormStatuses(formId, organizationId);
if (statuses.length === 0) {
const defaultStatus = await storage.createFormStatus({
formId,
organizationId,
name: "Новый",
color: "#e5e7eb",
isInitial: true,
isFinal: false,
position: 0,
} as any);
statuses = [defaultStatus];
}
const initialStatus = statuses[0];
// Build title from template or fallback
const title = form.titleTemplate
? contract.number
: `Договор №${contract.number}`;
// Extract parentTaskId from contract-number field
const contractValue = JSON.stringify({
mode: "theirs",
value: contract.number,
legalEntityTaskId: omegaPromTaskId,
});
const task = await storage.createTask({
title,
formId,
organizationId,
createdBy,
currentStatusId: initialStatus.id,
description: null,
assignedTo: null,
dueDate: null,
completedAt: null,
parentTaskId: omegaPromTaskId,
} as any);
// Insert field values
const fieldValues = [
{ fieldId: contractField.id, value: contractValue },
{ fieldId: dateField.id, value: contract.date },
{ fieldId: customerField.id, value: String(contract.customerTaskId) },
];
for (const { fieldId, value } of fieldValues) {
await storage.createTaskFieldValue({
taskId: task.id,
fieldId,
formId,
value,
});
}
// Create task relation for customer field if needed
if (customerField.taskRelationType) {
if (customerField.taskRelationType === "parent") {
await storage.upsertTaskRelation({
parentTaskId: contract.customerTaskId,
childTaskId: task.id,
fieldId: customerField.id,
organizationId,
});
} else if (customerField.taskRelationType === "child") {
await storage.upsertTaskRelation({
parentTaskId: task.id,
childTaskId: contract.customerTaskId,
fieldId: customerField.id,
organizationId,
});
}
}
// Recalculate title if template exists
if (form.titleTemplate) {
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getTaskFieldValues(task.id, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
]);
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
const { buildSystemFieldValues } = await import("../routes/shared");
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap });
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
const computedTitle = form.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValues.has(code)) return sysValues.get(code)!;
const fld = fieldCodeMap.get(code);
if (!fld) return "";
const val = valueByFieldId.get(fld.id);
return val === null || val === undefined ? "" : formatFieldValueForTitle(val, fld.type, { usersMap, rolesMap, tableRowMap }, fld.options);
});
await storage.updateTask(task.id, organizationId, { title: computedTitle });
}
return { taskId: task.id, title };
}
export async function applyContractSync(
formId: number,
organizationId: number,
createdBy: number,
contracts: SyncApplyContract[]
): Promise<{ created: number; skipped: number; errors: Array<{ raw: string; error: string }> }> {
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
if (!omegaPromTaskId) {
throw new Error("Не найдено юр.лицо ОМЕГАПРОМ");
}
const existingContracts = await findExistingContracts(formId, organizationId);
let created = 0;
let skipped = 0;
const errors: Array<{ raw: string; error: string }> = [];
for (const contract of contracts) {
try {
const key = `${contract.number}:${contract.customerTaskId}`;
if (existingContracts.has(key)) {
skipped++;
continue;
}
await createContractTask(formId, organizationId, createdBy, contract, omegaPromTaskId);
created++;
} catch (err: any) {
errors.push({ raw: contract.raw, error: err.message || "Ошибка создания" });
}
}
return { created, skipped, errors };
}