- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
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 };
|
||
}
|