import { type Express, type Request, type Response } from "express"; import { query, testConnection, setConfig, getConfig, isConnected } from "./db-client"; import { registerSalaryRoutes } from "./salary-routes"; import { ensureFinanceTables, ensureViews } from "./views"; import { registerOverrideRoutes } from "./overrides"; import { registerTagRoutes } from "./tags"; import { registerAutoDetectRoutes } from "./auto-detect"; import { registerTimeseriesRoutes } from "./timeseries"; import * as cache from "./cache"; import { getTableConfig, getIncomeCategories, setIncomeCategories, getExpenseCategories, setExpenseCategories, getIncomeAutoDetectConfig, setIncomeAutoDetectConfig, getExpenseAutoDetectConfig, setExpenseAutoDetectConfig, } from "./table-config"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync"; import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync"; import { getFinanceSettings, setFinanceSettings } from "./settings"; // Rebuild status (in-memory) let rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: null as string | null }; function buildWhere( filters: Record, dateFrom?: string, dateTo?: string, dateColumn = "data" ): { clause: string; params: any[]; paramIndex: number } { const conditions: string[] = []; const params: any[] = []; let idx = 1; for (const [col, val] of Object.entries(filters)) { if (!val) continue; conditions.push(`"${col}" ILIKE $${idx}`); params.push(`%${val}%`); idx++; } if (dateFrom) { conditions.push(`"${dateColumn}" >= $${idx}`); params.push(dateFrom); idx++; } if (dateTo) { conditions.push(`"${dateColumn}" <= $${idx}`); params.push(dateTo); idx++; } const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; return { clause, params, paramIndex: idx }; } function applyCategoryRules(row: Record, categories: any[]): string | null { for (const cat of categories) { const match = cat.ruleLogic === "or" ? cat.rules.some((r: any) => matchRule(row, r)) : cat.rules.every((r: any) => matchRule(row, r)); if (match) { if (cat.children && cat.children.length > 0) { const child = applyCategoryRules(row, cat.children); if (child) return child; } return cat.id; } } return null; } function matchRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; const strVal = val == null ? "" : String(val); const numVal = parseFloat(strVal); const ruleNum = parseFloat(rule.value); switch (rule.operator) { case "equals": return strVal === rule.value; case "not_equals": return strVal !== rule.value; case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase()); case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum; case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum; case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum; default: return false; } } // ===================== // Connection & Config // ===================== function registerConnectionRoutes(app: Express) { app.get("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, config: getConfig() }); }); app.post("/api/connection/test", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { const result = await testConnection(req.body); res.json(result); }); app.post("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { setConfig(req.body); cache.invalidate(); res.json({ success: true, message: "Конфигурация сохранена" }); }); app.get("/api/db-status", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { const connected = await isConnected().catch(() => false); res.json({ connected }); }); app.get("/api/db/tables", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const result = await query( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name` ); res.json({ success: true, tables: result.rows.map((r) => r.table_name) }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/db/columns/:table", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const result = await query( `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`, [req.params.table] ); res.json({ success: true, columns: result.rows }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/table-config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, config: getTableConfig() }); }); app.get("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, settings: getFinanceSettings() }); }); app.post("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { setFinanceSettings(req.body); res.json({ success: true }); }); } // ===================== // Income // ===================== function registerIncomeRoutes(app: Express) { app.get("/api/income", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const page = Math.max(1, parseInt(req.query.page as string) || 1); const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50)); const sortBy = (req.query.sortBy as string) || "id"; const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC"; const filters: Record = req.query.filters ? JSON.parse(req.query.filters as string) : {}; const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const cacheKey = `income:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } const { clause, params } = buildWhere(filters, dateFrom, dateTo); const countResult = await query(`SELECT COUNT(*) FROM "${config.incomeTable}" ${clause}`, params); const total = parseInt(countResult.rows[0].count, 10); const sumResult = await query(`SELECT COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${clause}`, params); const totalSum = parseFloat(sumResult.rows[0].total); const offset = (page - 1) * pageSize; const rowsResult = await query( `SELECT * FROM "${config.incomeTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, pageSize, offset] ); const result = { success: true, rows: rowsResult.rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum, }; cache.set(cacheKey, result); res.json(result); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/income/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const categories = getIncomeCategories(); const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const cacheKey = `income:tree:${dateFrom}:${dateTo}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } const { clause, params } = buildWhere({}, dateFrom, dateTo); const result = await query( `SELECT * FROM "${config.incomeTable}" ${clause}`, params ); const rows = result.rows; const treeTotals = new Map(); for (const row of rows) { const sum = parseFloat(row[config.incomeSumColumn]) || 0; const catId = applyCategoryRules(row, categories); if (catId) { const existing = treeTotals.get(catId) || { total: 0, count: 0 }; existing.total += sum; existing.count++; const date = row.data; if (date) { if (!existing.minDate || date < existing.minDate) existing.minDate = date; if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date; } treeTotals.set(catId, existing); } } const uncategorized = rows.reduce((acc, row) => { const catId = applyCategoryRules(row, categories); if (!catId) { acc.total += parseFloat(row[config.incomeSumColumn]) || 0; acc.count++; } return acc; }, { total: 0, count: 0 }); const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0); function buildTreeNodes(nodes: any[]): any[] { return nodes.map((node) => { const stats = treeTotals.get(node.id) || { total: 0, count: 0 }; return { id: node.id, name: node.name, total: stats.total, count: stats.count, excluded: node.excluded, autoDetected: node.autoDetected, minDate: stats.minDate, maxDate: stats.maxDate, children: node.children ? buildTreeNodes(node.children) : undefined, }; }).filter((n) => n.total > 0 || (n.children && n.children.length > 0)); } const trees = categories.map((tree) => ({ id: tree.id, name: tree.name, isPrimary: tree.treeId === "primary", total: (treeTotals.get(tree.id) || { total: 0 }).total, count: (treeTotals.get(tree.id) || { count: 0 }).count, nodes: buildTreeNodes(tree.children || []), })); const response = { success: true, trees, uncategorized, grandTotal: { total: grandTotal, count: rows.length }, }; cache.set(cacheKey, response); res.json(response); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/income/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const column = req.query.column as string; const search = (req.query.search as string) || ""; const context = req.query.context ? JSON.parse(req.query.context as string) : {}; if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; } const { clause, params } = buildWhere(context); const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : ""; if (search) params.push(`%${search}%`); const result = await query( `SELECT DISTINCT "${column}" FROM "${config.incomeTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`, params ); res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/income/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const categoryId = req.params.categoryId; const page = Math.max(1, parseInt(req.query.page as string) || 1); const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50)); const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const filters: Record = req.query.filters ? JSON.parse(req.query.filters as string) : {}; const categories = getIncomeCategories(); function findCategory(nodes: any[], id: string): any | null { for (const node of nodes) { if (node.id === id) return node; if (node.children) { const found = findCategory(node.children, id); if (found) return found; } } return null; } const category = findCategory(categories, categoryId); const { clause, params } = buildWhere(filters, dateFrom, dateTo); const result = await query(`SELECT * FROM "${config.incomeTable}" ${clause}`, params); const allRows = result.rows; const matchedRows = category ? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId) : allRows.filter((row) => !applyCategoryRules(row, categories)); const total = matchedRows.length; const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0); const offset = (page - 1) * pageSize; const rows = matchedRows.slice(offset, offset + pageSize); res.json({ success: true, rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum, }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); } // ===================== // Expenses (mirror of income) // ===================== function registerExpenseRoutes(app: Express) { app.get("/api/expenses", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const page = Math.max(1, parseInt(req.query.page as string) || 1); const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50)); const sortBy = (req.query.sortBy as string) || "id"; const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC"; const filters: Record = req.query.filters ? JSON.parse(req.query.filters as string) : {}; const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const cacheKey = `expenses:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } const { clause, params } = buildWhere(filters, dateFrom, dateTo); const countResult = await query(`SELECT COUNT(*) FROM "${config.expenseTable}" ${clause}`, params); const total = parseInt(countResult.rows[0].count, 10); const sumResult = await query(`SELECT COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${clause}`, params); const totalSum = parseFloat(sumResult.rows[0].total); const offset = (page - 1) * pageSize; const rowsResult = await query( `SELECT * FROM "${config.expenseTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, pageSize, offset] ); const result = { success: true, rows: rowsResult.rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum, }; cache.set(cacheKey, result); res.json(result); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/expenses/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const categories = getExpenseCategories(); const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const cacheKey = `expenses:tree:${dateFrom}:${dateTo}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } const { clause, params } = buildWhere({}, dateFrom, dateTo); const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params); const rows = result.rows; const treeTotals = new Map(); for (const row of rows) { const sum = parseFloat(row[config.expenseSumColumn]) || 0; const catId = applyCategoryRules(row, categories); if (catId) { const existing = treeTotals.get(catId) || { total: 0, count: 0 }; existing.total += sum; existing.count++; const date = row.data; if (date) { if (!existing.minDate || date < existing.minDate) existing.minDate = date; if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date; } treeTotals.set(catId, existing); } } const uncategorized = rows.reduce((acc, row) => { const catId = applyCategoryRules(row, categories); if (!catId) { acc.total += parseFloat(row[config.expenseSumColumn]) || 0; acc.count++; } return acc; }, { total: 0, count: 0 }); const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0); function buildTreeNodes(nodes: any[]): any[] { return nodes.map((node) => { const stats = treeTotals.get(node.id) || { total: 0, count: 0 }; return { id: node.id, name: node.name, total: stats.total, count: stats.count, excluded: node.excluded, autoDetected: node.autoDetected, minDate: stats.minDate, maxDate: stats.maxDate, children: node.children ? buildTreeNodes(node.children) : undefined, }; }).filter((n) => n.total > 0 || (n.children && n.children.length > 0)); } const trees = categories.map((tree) => ({ id: tree.id, name: tree.name, isPrimary: tree.treeId === "primary", total: (treeTotals.get(tree.id) || { total: 0 }).total, count: (treeTotals.get(tree.id) || { count: 0 }).count, nodes: buildTreeNodes(tree.children || []), })); const response = { success: true, trees, uncategorized, grandTotal: { total: grandTotal, count: rows.length } }; cache.set(cacheKey, response); res.json(response); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/expenses/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const column = req.query.column as string; const search = (req.query.search as string) || ""; const context = req.query.context ? JSON.parse(req.query.context as string) : {}; if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; } const { clause, params } = buildWhere(context); const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : ""; if (search) params.push(`%${search}%`); const result = await query( `SELECT DISTINCT "${column}" FROM "${config.expenseTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`, params ); res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); app.get("/api/expenses/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const config = getTableConfig(); const categoryId = req.params.categoryId; const page = Math.max(1, parseInt(req.query.page as string) || 1); const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50)); const dateFrom = req.query.dateFrom as string; const dateTo = req.query.dateTo as string; const filters: Record = req.query.filters ? JSON.parse(req.query.filters as string) : {}; const categories = getExpenseCategories(); function findCategory(nodes: any[], id: string): any | null { for (const node of nodes) { if (node.id === id) return node; if (node.children) { const f = findCategory(node.children, id); if (f) return f; } } return null; } const category = findCategory(categories, categoryId); const { clause, params } = buildWhere(filters, dateFrom, dateTo); const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params); const allRows = result.rows; const matchedRows = category ? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId) : allRows.filter((row) => !applyCategoryRules(row, categories)); const total = matchedRows.length; const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0); const offset = (page - 1) * pageSize; const rows = matchedRows.slice(offset, offset + pageSize); res.json({ success: true, rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); } // ===================== // Categories // ===================== function registerCategoryRoutes(app: Express) { app.get("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, categories: getIncomeCategories() }); }); app.post("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getIncomeCategories(); const newCat = { ...req.body, id: crypto.randomUUID(), children: [] }; current.push(newCat); setIncomeCategories(current); cache.invalidatePrefix("income:"); res.status(201).json({ success: true, category: newCat }); }); app.put("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getIncomeCategories(); function update(nodes: any[]): boolean { for (let i = 0; i < nodes.length; i++) { if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; } if (nodes[i].children && update(nodes[i].children)) return true; } return false; } update(current); setIncomeCategories(current); cache.invalidatePrefix("income:"); res.json({ success: true }); }); app.patch("/api/income-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getIncomeCategories(); function toggle(nodes: any[]): boolean { for (const node of nodes) { if (node.id === req.params.id) { node.excluded = !node.excluded; return true; } if (node.children && toggle(node.children)) return true; } return false; } toggle(current); setIncomeCategories(current); cache.invalidatePrefix("income:"); res.json({ success: true }); }); app.delete("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getIncomeCategories(); function remove(nodes: any[]): any[] { return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] })); } setIncomeCategories(remove(current)); cache.invalidatePrefix("income:"); res.json({ success: true }); }); // Expense categories (mirror) app.get("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, categories: getExpenseCategories() }); }); app.post("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getExpenseCategories(); const newCat = { ...req.body, id: crypto.randomUUID(), children: [] }; current.push(newCat); setExpenseCategories(current); cache.invalidatePrefix("expenses:"); res.status(201).json({ success: true, category: newCat }); }); app.put("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getExpenseCategories(); function update(nodes: any[]): boolean { for (let i = 0; i < nodes.length; i++) { if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; } if (nodes[i].children && update(nodes[i].children)) return true; } return false; } update(current); setExpenseCategories(current); cache.invalidatePrefix("expenses:"); res.json({ success: true }); }); app.patch("/api/expense-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getExpenseCategories(); function toggle(nodes: any[]): boolean { for (const node of nodes) { if (node.id === req.params.id) { node.excluded = !node.excluded; return true; } if (node.children && toggle(node.children)) return true; } return false; } toggle(current); setExpenseCategories(current); cache.invalidatePrefix("expenses:"); res.json({ success: true }); }); app.delete("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const current = getExpenseCategories(); function remove(nodes: any[]): any[] { return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] })); } setExpenseCategories(remove(current)); cache.invalidatePrefix("expenses:"); res.json({ success: true }); }); } // ===================== // Cache & Admin // ===================== function registerAdminRoutes(app: Express) { app.post("/api/cache/invalidate", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { cache.invalidate(req.body.pattern); res.json({ success: true }); }); app.post("/api/refresh-all", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { cache.invalidate(); rebuildStatus = { inProgress: true, pendingRebuild: true, lastRebuiltAt: null }; setTimeout(() => { rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: new Date().toISOString() }; }, 2000); res.json({ success: true, message: "Синхронизация запущена" }); }); app.get("/api/admin/rebuild-status", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, ...rebuildStatus }); }); app.post("/api/finance/customer-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.body.formId) || 8; const result = await previewCustomerSync(formId, req.organizationId!); res.json({ success: true, ...result }); } catch (err: any) { console.error("Customer sync preview error:", err); res.status(500).json({ success: false, error: err.message || "Ошибка при анализе заказчиков" }); } }); app.post("/api/finance/customer-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.body.formId) || 8; const customers = (req.body.customers || []) as SyncApplyCustomer[]; const result = await applyCustomerSync(formId, req.organizationId!, req.user!.id, customers); res.json({ success: true, ...result }); } catch (err: any) { console.error("Customer sync apply error:", err); res.status(500).json({ success: false, error: err.message || "Ошибка при создании заказчиков" }); } }); // Contract sync endpoints app.post("/api/finance/contract-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.body.formId) || 9; const result = await previewContractSync(formId, req.organizationId!); res.json({ success: true, ...result }); } catch (err: any) { console.error("Contract sync preview error:", err); res.status(500).json({ success: false, error: err.message || "Ошибка при анализе договоров" }); } }); app.post("/api/finance/contract-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.body.formId) || 9; const contracts = (req.body.contracts || []) as SyncApplyContract[]; const result = await applyContractSync(formId, req.organizationId!, req.user!.id, contracts); res.json({ success: true, ...result }); } catch (err: any) { console.error("Contract sync apply error:", err); res.status(500).json({ success: false, error: err.message || "Ошибка при создании договоров" }); } }); } export async function registerFinanceRoutes(app: Express): Promise { await ensureFinanceTables(); await ensureViews(); registerConnectionRoutes(app); registerIncomeRoutes(app); registerExpenseRoutes(app); registerCategoryRoutes(app); registerAdminRoutes(app); registerOverrideRoutes(app); registerTagRoutes(app); registerAutoDetectRoutes(app); registerTimeseriesRoutes(app); await registerSalaryRoutes(app); }