import { type Express } from "express"; import { query } from "./db-client"; import { getIncomeAutoDetectConfig, setIncomeAutoDetectConfig, getExpenseAutoDetectConfig, setExpenseAutoDetectConfig, getIncomeCategories, setIncomeCategories, getExpenseCategories, setExpenseCategories, getTableConfig, } from "./table-config"; import * as cache from "./cache"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; function getConfig(type: string) { return type === "expense" ? getExpenseAutoDetectConfig() : getIncomeAutoDetectConfig(); } function setConfig(type: string, config: any) { return type === "expense" ? setExpenseAutoDetectConfig(config) : setIncomeAutoDetectConfig(config); } function getCategories(type: string) { return type === "expense" ? getExpenseCategories() : getIncomeCategories(); } function setCategories(type: string, cats: any[]) { return type === "expense" ? setExpenseCategories(cats) : setIncomeCategories(cats); } function buildWhere(filters: any[], filterLogic: string): string { if (!filters || filters.length === 0) return ""; const logic = filterLogic === "or" ? " OR " : " AND "; const clauses = filters.map((f: any) => `"${f.column}" ILIKE '%${f.value.replace(/'/g, "''")}%'"`); return "WHERE " + clauses.join(logic); } export function registerAutoDetectRoutes(app: Express): void { // GET /api/:type-categories/auto-detect/config app.get("/api/income-categories/auto-detect/config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, config: getIncomeAutoDetectConfig() }); }); app.get("/api/expense-categories/auto-detect/config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { res.json({ success: true, config: getExpenseAutoDetectConfig() }); }); // POST /api/:type-categories/auto-detect/config/trees app.post("/api/income-categories/auto-detect/config/trees", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getIncomeAutoDetectConfig(); const tree = { ...req.body, id: crypto.randomUUID() }; config.trees.push(tree); setIncomeAutoDetectConfig(config); res.status(201).json({ success: true, tree }); }); app.post("/api/expense-categories/auto-detect/config/trees", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getExpenseAutoDetectConfig(); const tree = { ...req.body, id: crypto.randomUUID() }; config.trees.push(tree); setExpenseAutoDetectConfig(config); res.status(201).json({ success: true, tree }); }); // PUT /api/:type-categories/auto-detect/config/trees/:treeId app.put("/api/income-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getIncomeAutoDetectConfig(); const idx = config.trees.findIndex((t) => t.id === req.params.treeId); if (idx >= 0) { config.trees[idx] = { ...config.trees[idx], ...req.body }; setIncomeAutoDetectConfig(config); } res.json({ success: true }); }); app.put("/api/expense-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getExpenseAutoDetectConfig(); const idx = config.trees.findIndex((t) => t.id === req.params.treeId); if (idx >= 0) { config.trees[idx] = { ...config.trees[idx], ...req.body }; setExpenseAutoDetectConfig(config); } res.json({ success: true }); }); // DELETE /api/:type-categories/auto-detect/config/trees/:treeId app.delete("/api/income-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getIncomeAutoDetectConfig(); config.trees = config.trees.filter((t) => t.id !== req.params.treeId); setIncomeAutoDetectConfig(config); res.json({ success: true }); }); app.delete("/api/expense-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { const config = getExpenseAutoDetectConfig(); config.trees = config.trees.filter((t) => t.id !== req.params.treeId); setExpenseAutoDetectConfig(config); res.json({ success: true }); }); // GET /api/:type-categories/auto-detect/preview?treeId=... app.get("/api/income-categories/auto-detect/preview", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await previewAutoDetect(req, res, "income"); }); app.get("/api/expense-categories/auto-detect/preview", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await previewAutoDetect(req, res, "expense"); }); // POST /api/:type-categories/auto-detect?treeId=... app.post("/api/income-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await runAutoDetect(req, res, "income"); }); app.post("/api/expense-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await runAutoDetect(req, res, "expense"); }); // POST /api/:type-categories/auto-detect/sync?treeId=... app.post("/api/income-categories/auto-detect/sync", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await runAutoDetect(req, res, "income", true); }); app.post("/api/expense-categories/auto-detect/sync", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { await runAutoDetect(req, res, "expense", true); }); // DELETE /api/:type-categories/auto-detect?treeId=... app.delete("/api/income-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { deleteAutoDetect(req, res, "income"); }); app.delete("/api/expense-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { deleteAutoDetect(req, res, "expense"); }); } async function previewAutoDetect(req: AuthenticatedRequest, res: any, type: string) { try { const treeId = req.query.treeId as string; const config = getConfig(type); const tree = config.trees.find((t: any) => t.id === treeId); if (!tree) { res.status(404).json({ success: false, error: "Дерево не найдено" }); return; } const tconf = getTableConfig(); const table = type === "expense" ? tconf.expenseTable : tconf.incomeTable; const colResult = await query(`SELECT DISTINCT "${tree.columns[0]}" FROM "${table}" WHERE "${tree.columns[0]}" IS NOT NULL LIMIT 20`); const categories = colResult.rows.map((r: any, i: number) => ({ id: crypto.randomUUID(), name: r[tree.columns[0]], rules: [{ column: tree.columns[0], operator: "equals" as const, value: r[tree.columns[0]] }], ruleLogic: "and", children: [], })); res.json({ success: true, categories }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } } async function runAutoDetect(req: AuthenticatedRequest, res: any, type: string, sync: boolean = false) { try { const treeId = req.query.treeId as string; const config = getConfig(type); const tree = config.trees.find((t: any) => t.id === treeId); if (!tree) { res.status(404).json({ success: false, error: "Дерево не найдено" }); return; } const tconf = getTableConfig(); const table = type === "expense" ? tconf.expenseTable : tconf.incomeTable; const { clause, params } = buildWhereQuery(tree.filters, tree.filterLogic); const colResult = await query(`SELECT DISTINCT "${tree.columns[0]}" FROM "${table}" ${clause} AND "${tree.columns[0]}" IS NOT NULL`, params); const currentCats = getCategories(type); const newCats = colResult.rows.map((r: any) => ({ id: crypto.randomUUID(), name: r[tree.columns[0]], rules: [{ column: tree.columns[0], operator: "equals", value: r[tree.columns[0]] }], ruleLogic: "and", excluded: false, autoDetected: true, treeId: tree.id, children: [], })); if (sync) { // Remove old auto-detected categories for this tree const cleaned = currentCats.filter((c: any) => c.treeId !== tree.id); setCategories(type, [...cleaned, ...newCats]); } else { setCategories(type, [...currentCats, ...newCats]); } cache.invalidatePrefix(`${type}:`); res.json({ success: true, created: newCats.length }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } } function deleteAutoDetect(req: AuthenticatedRequest, res: any, type: string) { const treeId = req.query.treeId as string; const currentCats = getCategories(type); const cleaned = currentCats.filter((c: any) => c.treeId !== treeId); setCategories(type, cleaned); cache.invalidatePrefix(`${type}:`); res.json({ success: true }); } function buildWhereQuery(filters: any[], filterLogic: string): { clause: string; params: any[] } { if (!filters || filters.length === 0) return { clause: "", params: [] }; const logic = filterLogic === "or" ? " OR " : " AND "; const conditions: string[] = []; const params: any[] = []; let idx = 1; for (const f of filters) { conditions.push(`"${f.column}" ILIKE $${idx}`); params.push(`%${f.value}%`); idx++; } return { clause: `WHERE ${conditions.join(logic)}`, params }; }