Files
iistwin/server/finance/customer-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

403 lines
13 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 { decrypt } from "../crypto";
import { sql } from "drizzle-orm";
import { db } from "../db";
import { formatFieldValueForTitle, buildTableRowMap } from "../routes/shared";
import { findCustomerByInn } from "./sync-utils";
export interface DadataCompany {
value: string;
data: {
inn: string;
kpp: string;
ogrn: string;
name: {
full_with_opf: string;
short_with_opf: string;
};
management?: {
name: string;
post: string;
};
address?: {
value: string;
};
state?: {
status: string;
};
};
}
export interface SyncPreviewItem {
name: string;
inn: string;
suggestions: DadataCompany[];
}
export interface SyncApplyCustomer {
name: string;
inn: string;
selectedDadata?: DadataCompany;
}
const OPF_LIST = ["ООО", "АО", "ПАО", "ЗАО", "ОАО", "ИП"];
function cleanCompanyName(name: string): string {
let cleaned = name.trim();
// Remove OPF from beginning with space after (e.g. "ООО Ромашка")
for (const opf of OPF_LIST) {
const regex = new RegExp(`^${opf}\\s+`, "i");
cleaned = cleaned.replace(regex, "");
}
// Cut everything from first occurrence of " OPF" (e.g. "АВТОДОК-СЕТЬ ООО ЭДО СБИС")
for (const opf of OPF_LIST) {
const idx = cleaned.toUpperCase().indexOf(` ${opf}`);
if (idx !== -1) {
cleaned = cleaned.substring(0, idx).trim();
break;
}
}
// Remove quotes
cleaned = cleaned.replace(/["']/g, "").trim();
return cleaned;
}
function normalizeInn(inn: string): string {
return inn.trim().toLowerCase();
}
function isValidInn(inn: string): boolean {
const trimmed = inn.trim();
if (!trimmed || trimmed === "0" || trimmed === "") return false;
return /^\d{10,12}$/.test(trimmed);
}
function getDadataValue(company: DadataCompany, key: string): string {
switch (key) {
case "name.full_with_opf":
return company.data.name?.full_with_opf || "";
case "name.short_with_opf":
return company.data.name?.short_with_opf || "";
case "inn":
return company.data.inn || "";
case "kpp":
return company.data.kpp || "";
case "ogrn":
return company.data.ogrn || "";
case "management.name":
return company.data.management?.name || "";
case "management.post":
return company.data.management?.post || "";
default:
return "";
}
}
export interface FinanceCustomer {
name: string;
inn: string;
}
export async function getUniqueFinanceCustomers(): Promise<{
customers: FinanceCustomer[];
skipped: number;
}> {
const config = getTableConfig();
const incomeResult = await query(
`SELECT DISTINCT "zakazchik" as name, "innzakazchika" as inn FROM "${config.incomeTable}" WHERE "zakazchik" IS NOT NULL AND "zakazchik" <> ''`
);
const customers: FinanceCustomer[] = [];
let skipped = 0;
const seenInns = new Set<string>();
for (const row of incomeResult.rows) {
const rawInn = row.inn ? String(row.inn).trim() : "";
if (!isValidInn(rawInn)) {
skipped++;
continue;
}
const inn = normalizeInn(rawInn);
if (seenInns.has(inn)) continue;
seenInns.add(inn);
customers.push({
name: cleanCompanyName(row.name || ""),
inn,
});
}
customers.sort((a, b) => a.inn.localeCompare(b.inn));
return { customers, skipped };
}
export async function findExistingCustomerInns(
formId: number,
organizationId: number
): Promise<Set<string>> {
const formFields = await storage.getFormFields(formId, organizationId);
const companyField = formFields.find((f) => f.type === "company");
if (!companyField || !companyField.companyAutofill) {
// Fallback: compare by name if no INN field configured
return new Set();
}
const autofill = companyField.companyAutofill as Record<string, string>;
const innFieldCode = autofill["inn"];
if (!innFieldCode) {
return new Set();
}
const innField = formFields.find((f) => f.code === innFieldCode);
if (!innField) {
return new Set();
}
const result = await db.execute(sql`
SELECT DISTINCT LOWER(TRIM(value #>> '{}')) as inn
FROM task_field_values
WHERE form_id = ${formId} AND field_id = ${innField.id} AND value IS NOT NULL AND TRIM(value #>> '{}') <> ''
`);
const existing = new Set<string>();
for (const row of result.rows as Array<{ inn: string }>) {
if (row.inn) existing.add(normalizeInn(row.inn));
}
return existing;
}
async function getDadataApiKey(organizationId: number): Promise<string | null> {
const orgService = await storage.getExternalServiceByType("dadata", organizationId);
if (orgService && orgService.apiKey) {
return decrypt(orgService.apiKey);
}
return process.env.DADATA_API_KEY || null;
}
export async function resolveCompanyViaDadata(
queryStr: string,
organizationId: number
): Promise<DadataCompany[]> {
const apiKey = await getDadataApiKey(organizationId);
if (!apiKey) {
return [];
}
const response = await fetch("https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Token ${apiKey}`,
},
body: JSON.stringify({ query: queryStr, count: 10 }),
});
if (!response.ok) {
console.error("Dadata API error:", await response.text());
return [];
}
const data = (await response.json()) as { suggestions: DadataCompany[] };
return data.suggestions || [];
}
export async function buildCustomFields(
formId: number,
organizationId: number,
company: DadataCompany
): Promise<{ title: string; customFields: Record<string, string> }> {
const formFields = await storage.getFormFields(formId, organizationId);
const companyField = formFields.find((f) => f.type === "company");
if (!companyField) {
throw new Error(`В форме ${formId} не найдено поле типа company`);
}
const cleanValue = cleanCompanyName(company.value);
const customFields: Record<string, string> = {};
customFields[`customField_${companyField.id}`] = cleanValue;
if (companyField.companyAutofill && typeof companyField.companyAutofill === "object") {
const autofill = companyField.companyAutofill as Record<string, string>;
for (const [dadataKey, targetFieldCode] of Object.entries(autofill)) {
if (!targetFieldCode || targetFieldCode === "none") continue;
const targetField = formFields.find((f) => f.code === targetFieldCode);
if (targetField) {
const value = getDadataValue(company, dadataKey);
customFields[`customField_${targetField.id}`] = value;
}
}
}
const title = cleanCompanyName(company.data.name?.short_with_opf || company.value);
return { title, customFields };
}
export async function createCustomerTask(
formId: number,
organizationId: number,
createdBy: number,
company: DadataCompany
): Promise<{ taskId: number; title: string }> {
const { title, customFields } = await buildCustomFields(formId, organizationId, company);
const form = await storage.getForm(formId, organizationId);
if (!form) {
throw new Error("Форма не найдена");
}
const statuses = await storage.getFormStatuses(formId, organizationId);
const initialStatus = statuses[0];
if (!initialStatus) {
throw new Error("У формы нет ни одного статуса");
}
const task = await storage.createTask({
title,
formId,
organizationId,
createdBy,
currentStatusId: initialStatus.id,
description: null,
assignedTo: null,
dueDate: null,
completedAt: null,
parentTaskId: null,
} as any);
const fieldMap = new Map((await storage.getFormFields(formId, organizationId)).map((f) => [f.id, f]));
for (const [fieldKey, value] of Object.entries(customFields)) {
const match = fieldKey.match(/^customField_(\d+)$/);
if (!match) continue;
const fieldId = parseInt(match[1]);
const field = fieldMap.get(fieldId);
if (!field) continue;
await storage.createTaskFieldValue({
taskId: task.id,
fieldId,
formId,
value,
});
}
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 };
}
function matchByName(suggestions: DadataCompany[], customerName: string): DadataCompany | null {
const target = cleanCompanyName(customerName).toUpperCase();
const matches = suggestions.filter((s) => {
const candidate = cleanCompanyName(s.value).toUpperCase();
return candidate === target;
});
if (matches.length === 1) return matches[0];
return null;
}
export async function previewCustomerSync(
formId: number,
organizationId: number
): Promise<{
auto: Array<{ name: string; inn: string; company: DadataCompany }>;
ambiguous: SyncPreviewItem[];
existing: Array<{ name: string; inn: string }>;
skipped: number;
}> {
const { customers, skipped } = await getUniqueFinanceCustomers();
const existingInns = await findExistingCustomerInns(formId, organizationId);
const auto: Array<{ name: string; inn: string; company: DadataCompany }> = [];
const ambiguous: SyncPreviewItem[] = [];
const existing: Array<{ name: string; inn: string }> = [];
for (const customer of customers) {
if (existingInns.has(customer.inn)) {
existing.push(customer);
continue;
}
let suggestions = await resolveCompanyViaDadata(customer.inn, organizationId);
// Filter by exact INN match — DaData sometimes returns similar INNs (e.g. 10-digit vs 12-digit)
suggestions = suggestions.filter((s) => normalizeInn(s.data.inn || "") === customer.inn);
if (suggestions.length === 1) {
auto.push({ name: customer.name, inn: customer.inn, company: suggestions[0] });
} else if (suggestions.length > 1) {
const matched = matchByName(suggestions, customer.name);
if (matched) {
auto.push({ name: customer.name, inn: customer.inn, company: matched });
} else {
ambiguous.push({ name: customer.name, inn: customer.inn, suggestions });
}
} else {
ambiguous.push({ name: customer.name, inn: customer.inn, suggestions: [] });
}
}
return { auto, ambiguous, existing, skipped };
}
export async function applyCustomerSync(
formId: number,
organizationId: number,
createdBy: number,
customers: SyncApplyCustomer[]
): Promise<{ created: number; skipped: number; errors: Array<{ name: string; inn: string; error: string }> }> {
let created = 0;
let skipped = 0;
const errors: Array<{ name: string; inn: string; error: string }> = [];
for (const customer of customers) {
try {
const company = customer.selectedDadata;
if (!company) {
errors.push({ name: customer.name, inn: customer.inn, error: "Не выбрана компания" });
continue;
}
// Guard against duplicates: re-check INN before creating
const existingId = await findCustomerByInn(customer.inn, organizationId);
if (existingId) {
skipped++;
continue;
}
await createCustomerTask(formId, organizationId, createdBy, company);
created++;
} catch (err: any) {
errors.push({ name: customer.name, inn: customer.inn, error: err.message || "Ошибка создания" });
}
}
return { created, skipped, errors };
}