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

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

370 lines
12 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 { 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 };
}