fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
369
server/finance/contract-sync.ts
Normal file
369
server/finance/contract-sync.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user