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(); 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> { 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; 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(); 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 { 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 { 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 }> { 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 = {}; customFields[`customField_${companyField.id}`] = cleanValue; if (companyField.companyAutofill && typeof companyField.companyAutofill === "object") { const autofill = companyField.companyAutofill as Record; 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 }; }