import type { Express } from "express"; import { createServer, type Server } from "http"; import { randomUUID } from "crypto"; import { query, testConnection, getConfig, setConfig, isConnectionError, getPoolClient } from "./db-client"; import { dbConnectionSchema } from "./schema"; import { cachedQuery, cacheInvalidateAll, cacheInvalidateByPrefix, cacheInvalidateKey, stableKey } from "./cache"; import fs from "fs"; import path from "path"; import { loadTableConfig, saveTableConfig, getIncomeTable, getExpenseTable, getIncomeEffectiveView, getExpenseEffectiveView, getDebtTable, getAllowedIncomeColumns, getAllowedExpenseColumns, getAllIncomeColumns, getAllExpenseColumns, getAutoDetectAllowedColumns, invalidateTableConfigCache } from "./table-config"; import { initOverrideTables, recreateEffectiveViews, saveIncomeOverride, saveExpenseOverride, deleteIncomeOverride, deleteExpenseOverride, getAllIncomeOverrides, getAllExpenseOverrides, extractColumnValuesFromCategoryPath, INCOME_OVERRIDE_COLS, EXPENSE_OVERRIDE_COLS, migrateJsonOverrides } from "./override-tables"; import type { Response } from "express"; import { getAuthUrl, handleCallback, getAuthStatus, logout, getSheetNames, getSheetData } from "./google-auth"; import { runIntegrityAudit, getAuditHistory } from "./audit-agent"; import { loadAiConfig, saveAiConfig, loadAiPlanningConfig, saveAiPlanningConfig, getAiLog, saveLogEntry } from "./ai-config"; function handleApiError(res: Response, err: unknown, fallbackMessage = "Ошибка базы данных") { console.error(err); if (isConnectionError(err)) { return res.status(503).json({ error: "База данных не подключена", connectionError: true }); } return res.status(500).json({ error: fallbackMessage }); } const TTL_SHORT = 2 * 60 * 1000; const TTL_LONG = 5 * 60 * 1000; function buildTokenILIKE(colExpr: string, value: string, params: unknown[]): string { const tokens = value.split(/\s+/).filter(Boolean); if (tokens.length === 0) return "TRUE"; const parts = tokens.map(t => { params.push(`%${t}%`); return `${colExpr} ILIKE $${params.length}`; }); return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`; } function matchTokens(text: string, value: string): boolean { const lower = text.toLowerCase(); return value.split(/\s+/).filter(Boolean).every(t => lower.includes(t.toLowerCase())); } const TTL_MAX_PERIOD = 3 * 60 * 1000; async function getMaxPeriod(): Promise { return cachedQuery("__max_period__", TTL_MAX_PERIOD, async () => { const res = await query(`SELECT MAX(period) as mp FROM ${getDebtTable()}`); return res.rows[0]?.mp ? new Date(res.rows[0].mp).toISOString() : new Date().toISOString(); }); } function invalidateServerCache() { cacheInvalidateAll(); invalidateCategoryMemCache(); } function parseDate(d: unknown): string | null { if (!d) return null; return new Date(d as string).toISOString(); } const ALLOWED_INCOME_COLUMNS_FALLBACK = ["data", "statya", "zakazchik", "dochka", "vnuchka", "otvetstvenniipokontraktu", "stavkands", "kommentarii", "dogovorkontragenta", "schetnaoplatu", "naznachenieplatezha", "dogovorzakazchika", "statyadop", "nomervhodyaschegodokumenta", "summa"]; const ALLOWED_EXPENSE_COLUMNS_FALLBACK = ["data", "summadokumenta", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands", "naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta", "dogovorzakazchika", "statyadop", "ssilkanomervhodyaschegodokumenta", "kontragent"]; function ALLOWED_INCOME_COLUMNS(): string[] { try { return getAllowedIncomeColumns(); } catch { return ALLOWED_INCOME_COLUMNS_FALLBACK; } } function ALLOWED_EXPENSE_COLUMNS(): string[] { try { return getAllowedExpenseColumns(); } catch { return ALLOWED_EXPENSE_COLUMNS_FALLBACK; } } function ALL_EXPENSE_COLS(): string[] { try { return getAllExpenseColumns(); } catch { return ["id", ...ALLOWED_EXPENSE_COLUMNS_FALLBACK, "dataobnovleniyazapisi"]; } } interface IncomeCategory { id: string; name: string; rules: { column: string; operator: string; value: string }[]; ruleLogic?: "and" | "or"; excluded?: boolean; children?: IncomeCategory[]; autoDetected?: boolean; treeId?: string; } const CATEGORIES_FILE = path.join(process.cwd(), "data", "income-categories.json"); const EXPENSE_CATEGORIES_FILE = path.join(process.cwd(), "data", "expense-categories.json"); const AUTO_DETECT_CONFIG_FILE = path.join(process.cwd(), "data", "auto-detect-config.json"); const INCOME_AUTO_DETECT_CONFIG_FILE = path.join(process.cwd(), "data", "income-auto-detect-config.json"); const CATEGORY_OVERRIDES_FILE = path.join(process.cwd(), "data", "category-overrides.json"); let _incomeCategoriesCache: IncomeCategory[] | null = null; let _expenseCategoriesCache: IncomeCategory[] | null = null; let _autoDetectConfigCache: AutoDetectConfig | null = null; let _incomeAutoDetectConfigCache: AutoDetectConfig | null = null; function invalidateCategoryMemCache() { _incomeCategoriesCache = null; _expenseCategoriesCache = null; _autoDetectConfigCache = null; _incomeAutoDetectConfigCache = null; } function getExpenseAutoDetectColumns(): string[] { return getAutoDetectAllowedColumns("expense"); } const MAX_TREE_DEPTH = 5; type TreeFilter = { column: string; operator: string; value: string }; interface AutoDetectTree { id: string; name: string; columns: string[]; autoRefresh: boolean; filters?: TreeFilter[]; filterLogic?: string; isPrimary?: boolean; mode?: "split" | "merge"; } interface AutoDetectConfig { trees: AutoDetectTree[]; } function loadAutoDetectConfig(): AutoDetectConfig { if (_autoDetectConfigCache !== null) return _autoDetectConfigCache; try { const raw = fs.readFileSync(AUTO_DETECT_CONFIG_FILE, "utf-8"); const parsed = JSON.parse(raw); if (parsed.trees && Array.isArray(parsed.trees)) { const validTrees = parsed.trees.filter((t: any) => t.id && t.name && Array.isArray(t.columns) && t.columns.length >= 1 && t.columns.length <= MAX_TREE_DEPTH && t.columns.every((c: string) => getExpenseAutoDetectColumns().includes(c)) ).map((t: any) => ({ ...t, isPrimary: t.isPrimary !== undefined ? t.isPrimary : true, })); _autoDetectConfigCache = { trees: validTrees }; return _autoDetectConfigCache; } if (parsed.columns && Array.isArray(parsed.columns)) { const migrated: AutoDetectConfig = { trees: [{ id: generateId(), name: parsed.columns.map((c: string) => { const labels: Record = { statya: "Статья", dochka: "Дочка", vnuchka: "Внучка", zakazchik: "Заказчик", kommentarii: "Комментарий", dogovorzakazchika: "Договор заказчика", dogovorkontragenta: "Договор контрагента", stavkands: "Ставка НДС", naznachenieplatezha: "Назначение платежа", otvetstvenniipokontraktu: "Ответственный", statyadop: "Статья (доп.)", ssilkanomervhodyaschegodokumenta: "Номер вх. документа", }; return labels[c] || c; }).join(" / "), columns: parsed.columns, autoRefresh: false, }], }; saveAutoDetectConfig(migrated); migrateExpenseCategoryTreeIds(migrated.trees[0].id); return migrated; } } catch {} const defaultCfg: AutoDetectConfig = { trees: [{ id: generateId(), name: "Статья / Дочка / Внучка", columns: ["statya", "dochka", "vnuchka"], autoRefresh: false, }], }; saveAutoDetectConfig(defaultCfg); migrateExpenseCategoryTreeIds(defaultCfg.trees[0].id); return defaultCfg; } function saveAutoDetectConfig(cfg: AutoDetectConfig) { fs.writeFileSync(AUTO_DETECT_CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8"); _autoDetectConfigCache = null; invalidateCategoryMemCache(); scheduleTreeRebuild(); } function getIncomeAutoDetectColumns(): string[] { return getAutoDetectAllowedColumns("income"); } function loadIncomeAutoDetectConfig(): AutoDetectConfig { if (_incomeAutoDetectConfigCache !== null) return _incomeAutoDetectConfigCache; try { const raw = fs.readFileSync(INCOME_AUTO_DETECT_CONFIG_FILE, "utf-8"); const parsed = JSON.parse(raw); if (parsed.trees && Array.isArray(parsed.trees)) { const validTrees = parsed.trees.filter((t: any) => t.id && t.name && Array.isArray(t.columns) && t.columns.length >= 1 && t.columns.length <= MAX_TREE_DEPTH && t.columns.every((c: string) => getIncomeAutoDetectColumns().includes(c)) ).map((t: any) => ({ ...t, isPrimary: t.isPrimary !== undefined ? t.isPrimary : true, })); _incomeAutoDetectConfigCache = { trees: validTrees }; return _incomeAutoDetectConfigCache; } } catch {} _incomeAutoDetectConfigCache = { trees: [] }; return _incomeAutoDetectConfigCache; } function saveIncomeAutoDetectConfig(cfg: AutoDetectConfig) { fs.writeFileSync(INCOME_AUTO_DETECT_CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8"); _incomeAutoDetectConfigCache = null; invalidateCategoryMemCache(); scheduleTreeRebuild(); } // ============================================================ // MATERIALIZED TREE TABLES // ============================================================ function rowMatchesCatRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column] ?? ""; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesCatRuleSet(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (rules.length === 0) return true; if (logic === "or") return rules.some(r => rowMatchesCatRule(row, r)); return rules.every(r => rowMatchesCatRule(row, r)); } function transliterateRu(str: string): string { const map: Record = { а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"j",к:"k",л:"l",м:"m", н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"sch", ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya", А:"a",Б:"b",В:"v",Г:"g",Д:"d",Е:"e",Ё:"yo",Ж:"zh",З:"z",И:"i",Й:"j",К:"k",Л:"l",М:"m", Н:"n",О:"o",П:"p",Р:"r",С:"s",Т:"t",У:"u",Ф:"f",Х:"kh",Ц:"ts",Ч:"ch",Ш:"sh",Щ:"sch", Ъ:"",Ы:"y",Ь:"",Э:"e",Ю:"yu",Я:"ya", }; return str.split("").map(c => map[c] !== undefined ? map[c] : c).join("") .replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase().slice(0, 28); } function getTreeTableName(type: "income" | "expense", treeName: string): string { const prefix = type === "income" ? "derevodokhodi" : "derevooprashodi"; const suffix = transliterateRu(treeName) || "tree"; return `${prefix}_${suffix}`; } type CatMatch = { id: string; name: string; pathNames: string[]; parentId: string | null; excluded: boolean }; function findDeepestCategoryMatch( row: Record, cats: IncomeCategory[], ancestorRules: IncomeCategory["rules"][] = [], ancestorNames: string[] = [], ancestorIds: string[] = [] ): CatMatch | null { for (let i = cats.length - 1; i >= 0; i--) { const cat = cats[i]; const logic = cat.ruleLogic || "and"; if (!ancestorRules.every(rs => rowMatchesCatRuleSet(row, rs))) continue; if (!rowMatchesCatRuleSet(row, cat.rules, logic)) continue; const names = [...ancestorNames, cat.name]; const ids = [...ancestorIds, cat.id]; if (cat.children && cat.children.length > 0) { const childMatch = findDeepestCategoryMatch(row, cat.children, [...ancestorRules, cat.rules], names, ids); if (childMatch) return childMatch; } const parentId = ancestorIds.length > 0 ? ancestorIds[ancestorIds.length - 1] : null; return { id: cat.id, name: cat.name, pathNames: names, parentId, excluded: cat.excluded || false }; } return null; } function findCatByIdInTree(cats: IncomeCategory[], id: string): IncomeCategory | null { for (const cat of cats) { if (cat.id === id) return cat; if (cat.children) { const f = findCatByIdInTree(cat.children, id); if (f) return f; } } return null; } function flattenCatsForCols(cats: IncomeCategory[]): IncomeCategory[] { const result: IncomeCategory[] = []; for (const cat of cats) { result.push(cat); if (cat.children) result.push(...flattenCatsForCols(cat.children)); } return result; } let treeRebuildInProgress = false; let treeRebuildPending = false; let treeRebuildLastCompletedAt: Date | null = null; const treeRebuildWaiters: Array<() => void> = []; async function buildOneTreeTable( type: "income" | "expense", tableName: string, allRows: Record[], treeCats: IncomeCategory[], isPrimary: boolean, excludeRowIds?: Set ): Promise> { const amountCol = type === "income" ? "summa" : "summadokumenta"; const fkCol = type === "income" ? "dohod_id" : "rashod_id"; await query(`DROP TABLE IF EXISTS "${tableName}"`); await query(`CREATE TABLE "${tableName}" ( id BIGSERIAL PRIMARY KEY, ${fkCol} TEXT, parent_id TEXT, data DATE, "${amountCol}" NUMERIC, stavkands TEXT, _category_id TEXT, _category_name TEXT, _category_path TEXT, _is_excluded BOOLEAN DEFAULT FALSE, _is_primary BOOLEAN DEFAULT TRUE )`); const claimedIds = new Set(); const toInsert: { fk: string; data: any; amount: number; stavkands: string; cat_id: string; parent_id: string | null; cat_name: string; cat_path: string; excluded: boolean }[] = []; for (const row of allRows) { if (claimedIds.has(row.id)) continue; if (excludeRowIds?.has(row.id)) continue; const match = findDeepestCategoryMatch(row, treeCats); if (!match) continue; toInsert.push({ fk: row.id, data: row.data, amount: row.amount, stavkands: row.stavkands, cat_id: match.id, parent_id: match.parentId, cat_name: match.name, cat_path: match.pathNames.join(" / "), excluded: match.excluded }); claimedIds.add(row.id); } const BATCH = 500; for (let i = 0; i < toInsert.length; i += BATCH) { const batch = toInsert.slice(i, i + BATCH); const valuePlaceholders: string[] = []; const params: unknown[] = []; for (const r of batch) { const base = params.length; params.push(r.fk, r.parent_id, r.data, r.amount, r.stavkands, r.cat_id, r.cat_name, r.cat_path, r.excluded, isPrimary); valuePlaceholders.push(`($${base+1},$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8},$${base+9},$${base+10})`); } await query( `INSERT INTO "${tableName}" (${fkCol}, parent_id, data, "${amountCol}", stavkands, _category_id, _category_name, _category_path, _is_excluded, _is_primary) VALUES ${valuePlaceholders.join(",")}`, params ); } const idxBase = tableName.replace(/[^a-z0-9]/gi, "_").slice(0, 40); await Promise.all([ query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_id" ON "${tableName}" (id)`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_fk" ON "${tableName}" (${fkCol})`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_pid" ON "${tableName}" (parent_id)`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_catid" ON "${tableName}" (_category_id)`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_data" ON "${tableName}" (data)`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_prim" ON "${tableName}" (_is_primary)`), ]); return claimedIds; } async function buildUncatTable(type: "income" | "expense", tableName: string, rows: Record[]): Promise { const amountCol = type === "income" ? "summa" : "summadokumenta"; const fkCol = type === "income" ? "dohod_id" : "rashod_id"; await query(`DROP TABLE IF EXISTS "${tableName}"`); await query(`CREATE TABLE "${tableName}" (id BIGSERIAL PRIMARY KEY, ${fkCol} TEXT, data DATE, "${amountCol}" NUMERIC, stavkands TEXT, _is_primary BOOLEAN DEFAULT FALSE)`); const BATCH = 500; for (let i = 0; i < rows.length; i += BATCH) { const batch = rows.slice(i, i + BATCH); if (batch.length === 0) continue; const ph: string[] = []; const params: unknown[] = []; for (const r of batch) { const b = params.length; params.push(r.id, r.data, r.amount, r.stavkands, false); ph.push(`($${b+1},$${b+2},$${b+3},$${b+4},$${b+5})`); } await query(`INSERT INTO "${tableName}" (${fkCol}, data, "${amountCol}", stavkands, _is_primary) VALUES ${ph.join(",")}`, params); } const idxBase = tableName.replace(/[^a-z0-9]/gi, "_").slice(0, 40); await Promise.all([ query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_fk" ON "${tableName}" (${fkCol})`), query(`CREATE INDEX IF NOT EXISTS "idx_${idxBase}_data" ON "${tableName}" (data)`), ]); } async function rebuildAllTreeTables(): Promise { if (treeRebuildInProgress) { treeRebuildPending = true; return; } do { treeRebuildInProgress = true; treeRebuildPending = false; try { console.log("[tree-tables] Starting rebuild..."); const incomeCats = loadCategories(); const expenseCats = loadExpenseCategories(); const incomeConfig = loadIncomeAutoDetectConfig(); const expenseConfig = loadAutoDetectConfig(); const incGrouped = new Map(); for (const cat of incomeCats) { const tid = cat.treeId || "__manual__"; if (!incGrouped.has(tid)) incGrouped.set(tid, []); incGrouped.get(tid)!.push(cat); } const incRuleCols = new Set(["statya","dochka","vnuchka","otvetstvenniipokontraktu","zakazchik","dogovorzakazchika"]); for (const cat of flattenCatsForCols(incomeCats)) { for (const r of cat.rules) { if (ALLOWED_INCOME_COLUMNS_FALLBACK.includes(r.column)) incRuleCols.add(r.column); } } const incColList = [...incRuleCols]; const incResult = await query(`SELECT id, data, COALESCE(summa,0) as summa, COALESCE(stavkands,'') as stavkands, ${incColList.map(c=>`COALESCE("${c}",'') as "${c}"`).join(",")} FROM ${getIncomeEffectiveView()}`); const incRows = incResult.rows.map((r: any) => { const row: Record = { id: String(r.id), data: r.data, amount: Number(r.summa), stavkands: String(r.stavkands || "") }; for (const c of incColList) row[c] = r[c] ?? ""; return row; }); const manualIncCats = incGrouped.get("__manual__") || []; const manualIncClaimedIds = await buildOneTreeTable("income", getTreeTableName("income", "Ручные категории"), incRows, manualIncCats, true); const allIncAutoClaimedIds = new Set(manualIncClaimedIds); for (const t of incomeConfig.trees) { const cats = incGrouped.get(t.id) || []; const claimed = await buildOneTreeTable("income", getTreeTableName("income", t.name), incRows, cats, t.isPrimary ?? true, manualIncClaimedIds); for (const id of claimed) allIncAutoClaimedIds.add(id); } const allIncCats = [...incGrouped.values()].flat(); const incUncatRows = incRows.filter(row => !allIncAutoClaimedIds.has(row.id) && !findDeepestCategoryMatch(row, allIncCats)); await buildUncatTable("income", getTreeTableName("income", "Нераспределённые"), incUncatRows); const expGrouped = new Map(); for (const cat of expenseCats) { const tid = cat.treeId || "__manual__"; if (!expGrouped.has(tid)) expGrouped.set(tid, []); expGrouped.get(tid)!.push(cat); } const expRuleCols = new Set(["statya","dochka","vnuchka","otvetstvenniipokontraktu","zakazchik","dogovorzakazchika"]); for (const cat of flattenCatsForCols(expenseCats)) { for (const r of cat.rules) { if (ALLOWED_EXPENSE_COLUMNS_FALLBACK.includes(r.column)) expRuleCols.add(r.column); } } const expColList = [...expRuleCols]; const expResult = await query(`SELECT id, data, COALESCE(summadokumenta,0) as summadokumenta, COALESCE(stavkands,'') as stavkands, ${expColList.map(c=>`COALESCE("${c}",'') as "${c}"`).join(",")} FROM ${getExpenseEffectiveView()}`); const expRows = expResult.rows.map((r: any) => { const row: Record = { id: String(r.id), data: r.data, amount: Number(r.summadokumenta), stavkands: String(r.stavkands || "") }; for (const c of expColList) row[c] = r[c] ?? ""; return row; }); const manualExpCats = expGrouped.get("__manual__") || []; const manualExpClaimedIds = await buildOneTreeTable("expense", getTreeTableName("expense", "Ручные категории"), expRows, manualExpCats, true); const allExpAutoClaimedIds = new Set(manualExpClaimedIds); for (const t of expenseConfig.trees) { const cats = expGrouped.get(t.id) || []; const claimed = await buildOneTreeTable("expense", getTreeTableName("expense", t.name), expRows, cats, t.isPrimary ?? true, manualExpClaimedIds); for (const id of claimed) allExpAutoClaimedIds.add(id); } const allExpCats = [...expGrouped.values()].flat(); const expUncatRows = expRows.filter(row => !allExpAutoClaimedIds.has(row.id) && !findDeepestCategoryMatch(row, allExpCats)); await buildUncatTable("expense", getTreeTableName("expense", "Нераспределённые"), expUncatRows); treeRebuildLastCompletedAt = new Date(); cacheInvalidateByPrefix("dashboard:"); cacheInvalidateByPrefix("profitability:"); cacheInvalidateKey("__income_auto_sync__"); cacheInvalidateKey("__expense_auto_sync__"); console.log("[tree-tables] Rebuild complete"); } catch (err) { console.error("[tree-tables] Rebuild error:", err); } finally { treeRebuildInProgress = false; } } while (treeRebuildPending); const waiters = treeRebuildWaiters.splice(0); for (const fn of waiters) fn(); } function scheduleTreeRebuild() { invalidateCategoryMemCache(); cacheInvalidateByPrefix("dashboard:"); cacheInvalidateByPrefix("profitability:"); if (treeRebuildInProgress) { treeRebuildPending = true; return; } setImmediate(() => rebuildAllTreeTables().catch(console.error)); } async function scheduleAndAwaitRebuild(): Promise { return new Promise(resolve => { treeRebuildWaiters.push(resolve); if (treeRebuildInProgress) { treeRebuildPending = true; } else { setImmediate(() => rebuildAllTreeTables().catch(console.error)); } }); } async function ensureTreesReady(): Promise { if (treeRebuildLastCompletedAt === null) { await scheduleAndAwaitRebuild(); } } async function treeTableExists(tableName: string): Promise { const r = await query(`SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 LIMIT 1`, [tableName]); return r.rows.length > 0; } async function getPrimaryTreeTables(): Promise<{ income: string[]; expense: string[] }> { const incConfig = loadIncomeAutoDetectConfig(); const expConfig = loadAutoDetectConfig(); const inc = [getTreeTableName("income", "Ручные категории")]; for (const t of incConfig.trees) { if (t.isPrimary) inc.push(getTreeTableName("income", t.name)); } const exp = [getTreeTableName("expense", "Ручные категории")]; for (const t of expConfig.trees) { if (t.isPrimary) exp.push(getTreeTableName("expense", t.name)); } return { income: inc, expense: exp }; } // ============================================================ function buildFilterSQL(filters: TreeFilter[] | undefined, filterLogic: string | undefined, params: unknown[], allowedColumns: string[]): string { if (!filters || filters.length === 0) return ""; const conds: string[] = []; for (const f of filters) { if (!allowedColumns.includes(f.column) || !f.value) continue; switch (f.operator) { case "equals": params.push(f.value); conds.push(`"${f.column}" = $${params.length}`); break; case "not_equals": params.push(f.value); conds.push(`"${f.column}" != $${params.length}`); break; case "contains": conds.push(buildTokenILIKE(`"${f.column}"`, f.value, params)); break; } } if (conds.length === 0) return ""; const logic = filterLogic === "or" ? " OR " : " AND "; return ` AND (${conds.join(logic)})`; } async function buildIncomeAutoDetectTree(cols: string[], treeId: string, filters?: TreeFilter[], filterLogic?: string): Promise { const selectCols = cols.map(c => `"${c}"`).join(", "); const orderCols = cols.map(c => `"${c}"`).join(", "); const params: unknown[] = []; const filterClause = buildFilterSQL(filters, filterLogic, params, ALLOWED_INCOME_COLUMNS()); const sql = `SELECT DISTINCT ${selectCols} FROM ${getIncomeEffectiveView()} WHERE "${cols[0]}" IS NOT NULL AND "${cols[0]}" != '0'${filterClause} ORDER BY ${orderCols}`; const result = await query(sql, params); type NestedMap = Map; const rootMap: NestedMap = new Map(); for (const row of result.rows) { const values: (string | null)[] = cols.map((c, i) => { const v = row[c] as string | null; return (v && v !== "0") ? v : (i === 0 ? null : null); }); if (!values[0]) continue; let currentMap = rootMap; for (let i = 0; i < values.length; i++) { const val = values[i]; if (!val) break; if (!currentMap.has(val)) currentMap.set(val, new Map()); currentMap = currentMap.get(val)!; } } function mapToCategories(map: NestedMap, colIndex: number): IncomeCategory[] { const cats: IncomeCategory[] = []; for (const [value, children] of map) { const cat: IncomeCategory = { id: generateId(), name: value, rules: [{ column: cols[colIndex], operator: "equals", value }], autoDetected: true, treeId, }; if (children.size > 0 && colIndex + 1 < cols.length) { cat.children = mapToCategories(children, colIndex + 1); } cats.push(cat); } return cats; } return mapToCategories(rootMap, 0); } function buildMergedCategory(treeCfg: AutoDetectTree): IncomeCategory[] { const rules: { column: string; operator: string; value: string }[] = []; if (treeCfg.filters && treeCfg.filters.length > 0) { for (const f of treeCfg.filters) { rules.push({ column: f.column, operator: f.operator, value: f.value }); } } else if (treeCfg.columns.length > 0) { rules.push({ column: treeCfg.columns[0], operator: "not_equals", value: "0" }); } return [{ id: generateId(), name: treeCfg.name, rules, ruleLogic: treeCfg.filterLogic === "or" ? "or" : undefined, autoDetected: true, treeId: treeCfg.id, }]; } function migrateAutoDetectedTreeIds(cats: IncomeCategory[], treeId: string): boolean { let changed = false; for (const cat of cats) { if (cat.autoDetected && !cat.treeId) { cat.treeId = treeId; changed = true; } if (cat.children) { if (migrateAutoDetectedTreeIds(cat.children, treeId)) changed = true; } } return changed; } function migrateExpenseCategoryTreeIds(treeId: string) { try { const raw = fs.readFileSync(EXPENSE_CATEGORIES_FILE, "utf-8"); const cats: IncomeCategory[] = JSON.parse(raw); if (migrateAutoDetectedTreeIds(cats, treeId)) { fs.writeFileSync(EXPENSE_CATEGORIES_FILE, JSON.stringify(cats, null, 2), "utf-8"); } } catch {} } function loadCategories(): IncomeCategory[] { if (_incomeCategoriesCache !== null) return _incomeCategoriesCache; try { const raw = fs.readFileSync(CATEGORIES_FILE, "utf-8"); _incomeCategoriesCache = JSON.parse(raw); return _incomeCategoriesCache!; } catch { _incomeCategoriesCache = []; return _incomeCategoriesCache; } } function saveCategories(cats: IncomeCategory[]) { fs.writeFileSync(CATEGORIES_FILE, JSON.stringify(cats, null, 2), "utf-8"); _incomeCategoriesCache = null; cacheInvalidateByPrefix("dashboard:"); cacheInvalidateByPrefix("profitability:"); scheduleTreeRebuild(); } function loadExpenseCategories(): IncomeCategory[] { if (_expenseCategoriesCache !== null) return _expenseCategoriesCache; try { const raw = fs.readFileSync(EXPENSE_CATEGORIES_FILE, "utf-8"); _expenseCategoriesCache = JSON.parse(raw); return _expenseCategoriesCache!; } catch { _expenseCategoriesCache = []; return _expenseCategoriesCache; } } function saveExpenseCategories(cats: IncomeCategory[]) { fs.writeFileSync(EXPENSE_CATEGORIES_FILE, JSON.stringify(cats, null, 2), "utf-8"); _expenseCategoriesCache = null; cacheInvalidateByPrefix("dashboard:"); cacheInvalidateByPrefix("profitability:"); scheduleTreeRebuild(); } function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } function findCategoryById(cats: IncomeCategory[], id: string): { cat: IncomeCategory; path: IncomeCategory[] } | null { for (const cat of cats) { if (cat.id === id) return { cat, path: [cat] }; if (cat.children && cat.children.length > 0) { const found = findCategoryById(cat.children, id); if (found) return { cat: found.cat, path: [cat, ...found.path] }; } } return null; } function findAndRemoveCategory(cats: IncomeCategory[], id: string): boolean { for (let i = 0; i < cats.length; i++) { if (cats[i].id === id) { cats.splice(i, 1); return true; } if (cats[i].children && findAndRemoveCategory(cats[i].children!, id)) return true; } return false; } function findAndUpdateCategory(cats: IncomeCategory[], id: string, updater: (cat: IncomeCategory) => void): boolean { for (const cat of cats) { if (cat.id === id) { updater(cat); return true; } if (cat.children && findAndUpdateCategory(cat.children, id, updater)) return true; } return false; } function addChildToCategory(cats: IncomeCategory[], parentId: string, child: IncomeCategory): boolean { for (const cat of cats) { if (cat.id === parentId) { if (!cat.children) cat.children = []; cat.children.push(child); return true; } if (cat.children && addChildToCategory(cat.children, parentId, child)) return true; } return false; } function getCategoryDepth(cats: IncomeCategory[], id: string): number { const found = findCategoryById(cats, id); return found ? found.path.length : 0; } function collectAncestorRules(cats: IncomeCategory[], id: string): IncomeCategory["rules"][] { const found = findCategoryById(cats, id); if (!found) return []; return found.path.map(c => c.rules); } function flattenCategories(cats: IncomeCategory[]): IncomeCategory[] { const result: IncomeCategory[] = []; for (const cat of cats) { result.push(cat); if (cat.children && cat.children.length > 0) { result.push(...flattenCategories(cat.children)); } } return result; } function isRefinementOf( autoRuleSets: IncomeCategory["rules"][], catRuleSets: IncomeCategory["rules"][] ): boolean { const autoRules = autoRuleSets.flat(); const catRules = catRuleSets.flat(); if (autoRules.length === 0) return true; const autoColumns = new Set(autoRules.map(r => r.column)); const catColumns = new Set(catRules.map(r => r.column)); if (![...autoColumns].some(col => catColumns.has(col))) return true; return autoRules.every(ar => catRules.some(cr => cr.column === ar.column && cr.operator === ar.operator && cr.value === ar.value ) ); } function deepCopyWithNewIds(cats: IncomeCategory[]): IncomeCategory[] { return cats.map(cat => ({ ...cat, id: generateId(), rules: [...cat.rules], children: cat.children ? deepCopyWithNewIds(cat.children) : undefined, })); } function deepCopyWithNewIdsManual(cats: IncomeCategory[]): IncomeCategory[] { return cats.map(cat => ({ ...cat, id: generateId(), rules: [...cat.rules], autoDetected: false, treeId: undefined, children: cat.children ? deepCopyWithNewIdsManual(cat.children) : undefined, })); } function findCategoryWithAncestors( cats: IncomeCategory[], targetId: string, ancestors: IncomeCategory[] = [] ): { cat: IncomeCategory; ancestors: IncomeCategory[] } | null { for (const cat of cats) { if (cat.id === targetId) return { cat, ancestors }; if (cat.children && cat.children.length > 0) { const found = findCategoryWithAncestors(cat.children, targetId, [...ancestors, cat]); if (found) return found; } } return null; } function buildRuleCondition(rule: IncomeCategory["rules"][0], params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS()): string | null { if (!allowedColumns.includes(rule.column)) return null; switch (rule.operator) { case "contains": return buildTokenILIKE(rule.column, rule.value, params); case "equals": params.push(rule.value); return `${rule.column} = $${params.length}`; case "not_equals": params.push(rule.value); return `${rule.column} != $${params.length}`; case "greater_than": params.push(Number(rule.value)); return `${rule.column} > $${params.length}`; case "less_than": params.push(Number(rule.value)); return `${rule.column} < $${params.length}`; case "equal_number": params.push(Number(rule.value)); return `${rule.column} = $${params.length}`; default: params.push(rule.value); return `${rule.column} = $${params.length}`; } } function buildCategoryConditions(rules: IncomeCategory["rules"], params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS()): string[] { const conditions: string[] = []; for (const rule of rules) { const cond = buildRuleCondition(rule, params, allowedColumns); if (cond) conditions.push(cond); } return conditions; } function buildCatGroupCondition(cat: IncomeCategory, params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS()): string | null { const catConds: string[] = []; for (const rule of cat.rules) { const cond = buildRuleCondition(rule, params, allowedColumns); if (cond) catConds.push(cond); } const joiner = cat.ruleLogic === "or" ? " OR " : " AND "; return catConds.length > 0 ? `(${catConds.join(joiner)})` : null; } function buildCategoryWithAncestorsConditions(cats: IncomeCategory[], categoryId: string, params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS()): string[] { const found = findCategoryById(cats, categoryId); if (!found) return []; const conditions: string[] = []; for (let i = 0; i < found.path.length; i++) { const cat = found.path[i]; if (i === found.path.length - 1 && cat.ruleLogic === "or") { const grouped = buildCatGroupCondition(cat, params, allowedColumns); if (grouped) conditions.push(grouped); } else { conditions.push(...buildCategoryConditions(cat.rules, params, allowedColumns)); } } return conditions; } type SqlPath = { id?: string; treeId?: string; ancestorAutoIds?: string[]; ruleSets: IncomeCategory["rules"][]; logic: "and" | "or" }; function collectNonExcludedPathsForSql(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][] = [], rootTreeId?: string): SqlPath[] { const result: SqlPath[] = []; for (const cat of cats) { const tid = rootTreeId || cat.treeId || "__manual__"; const path = [...ancestors, cat.rules]; if (!cat.excluded) { result.push({ treeId: tid, ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) { result.push(...collectNonExcludedPathsForSql(cat.children, path, tid)); } } return result; } function buildExcludeAllCategoriesConditions(cats: IncomeCategory[], params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS(), onlyPrimaryTreeIds?: Set): string[] { let paths = collectNonExcludedPathsForSql(cats); if (onlyPrimaryTreeIds) { paths = paths.filter(p => { const tid = p.treeId || "__manual__"; return tid === "__manual__" || onlyPrimaryTreeIds.has(tid); }); } if (paths.length === 0) return []; const excludeParts: string[] = []; for (const p of paths) { const part = buildPathConditionSql(p.ruleSets, p.logic, params, allowedColumns); if (part) excludeParts.push(part); } if (excludeParts.length === 0) return []; return [`NOT (${excludeParts.join(" OR ")})`]; } function buildIncludePrimaryTreeConditions(cats: IncomeCategory[], params: unknown[], allowedColumns: string[], primaryTreeIds: Set): string[] { let paths = collectNonExcludedPathsForSql(cats); paths = paths.filter(p => { const tid = p.treeId || "__manual__"; return tid === "__manual__" || primaryTreeIds.has(tid); }); if (paths.length === 0) return ["1=0"]; const parts: string[] = []; for (const p of paths) { const part = buildPathConditionSql(p.ruleSets, p.logic, params, allowedColumns); if (part) parts.push(part); } if (parts.length === 0) return ["1=0"]; return [`(${parts.join(" OR ")})`]; } function buildPathConditionSql( ruleSets: IncomeCategory["rules"][], logic: "and" | "or", params: unknown[], allowedColumns: string[] ): string | null { const pathConds: string[] = []; for (let i = 0; i < ruleSets.length; i++) { const rules = ruleSets[i]; if (!rules || rules.length === 0) continue; const ruleLogic = i === ruleSets.length - 1 ? logic : "and"; const ruleConds: string[] = []; for (const rule of rules) { const cond = buildRuleCondition(rule, params, allowedColumns); if (cond) ruleConds.push(cond); } if (ruleConds.length > 0) { const joiner = ruleLogic === "or" ? " OR " : " AND "; pathConds.push(`(${ruleConds.join(joiner)})`); } } return pathConds.length > 0 ? `(${pathConds.join(" AND ")})` : null; } function hasNonExcludedDescendant(cat: IncomeCategory): boolean { if (!cat.children) return false; for (const ch of cat.children) { if (!ch.excluded) return true; if (hasNonExcludedDescendant(ch)) return true; } return false; } function collectExcludedPathsForSql(cats: IncomeCategory[], skipIds: Set, ancestors: IncomeCategory["rules"][] = [], rootTreeId?: string): SqlPath[] { const result: SqlPath[] = []; for (const cat of cats) { const tid = rootTreeId || cat.treeId || "__manual__"; const path = [...ancestors, cat.rules]; if (cat.excluded && !skipIds.has(cat.id)) { if (!hasNonExcludedDescendant(cat)) { result.push({ id: cat.id, treeId: tid, ruleSets: path, logic: cat.ruleLogic || "and" }); } } if (cat.children) { result.push(...collectExcludedPathsForSql(cat.children, skipIds, path, tid)); } } return result; } function collectAutoPathsForSql(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][] = [], ancestorAutoIds: string[] = []): SqlPath[] { const result: SqlPath[] = []; for (const cat of cats) { if (cat.excluded) continue; const path = [...ancestors, cat.rules]; const autoIds = cat.autoDetected ? [...ancestorAutoIds, cat.id] : ancestorAutoIds; if (cat.autoDetected) { result.push({ id: cat.id, ancestorAutoIds: [...ancestorAutoIds], ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) { result.push(...collectAutoPathsForSql(cat.children, path, autoIds)); } } return result; } function buildGlobalExclusionConditions(cats: IncomeCategory[], params: unknown[], skipCategoryIds?: string | Set, allowedColumns: string[] = ALLOWED_INCOME_COLUMNS(), onlyPrimaryTreeIds?: Set, manualCatRuleSets?: IncomeCategory["rules"][]): string[] { const skipSet = skipCategoryIds instanceof Set ? skipCategoryIds : (skipCategoryIds ? new Set([skipCategoryIds]) : new Set()); let paths = collectExcludedPathsForSql(cats, skipSet); if (onlyPrimaryTreeIds) { paths = paths.filter(p => { const tid = p.treeId || "__manual__"; return tid === "__manual__" || onlyPrimaryTreeIds.has(tid); }); } if (manualCatRuleSets && manualCatRuleSets.length > 0) { paths = paths.filter(p => !isRefinementOf(p.ruleSets, manualCatRuleSets)); } if (paths.length === 0) return []; const excludeParts: string[] = []; for (const p of paths) { const part = buildPathConditionSql(p.ruleSets, p.logic, params, allowedColumns); if (part) excludeParts.push(part); } if (excludeParts.length === 0) return []; return [`NOT (${excludeParts.join(" OR ")})`]; } function buildAutoPathExclusionConditions(cats: IncomeCategory[], params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS(), skipIds?: Set, manualCatRuleSets?: IncomeCategory["rules"][]): string[] { let paths = collectAutoPathsForSql(cats); if (skipIds && skipIds.size > 0) { paths = paths.filter(p => { if (p.id && skipIds.has(p.id)) return false; if (p.ancestorAutoIds && p.ancestorAutoIds.some(aid => skipIds.has(aid))) return false; return true; }); } if (manualCatRuleSets && manualCatRuleSets.length > 0) { paths = paths.filter(p => !isRefinementOf(p.ruleSets, manualCatRuleSets) && !isRefinementOf(manualCatRuleSets, p.ruleSets) ); } if (paths.length === 0) return []; const parts: string[] = []; for (const p of paths) { const part = buildPathConditionSql(p.ruleSets, p.logic, params, allowedColumns); if (part) parts.push(part); } if (parts.length === 0) return []; return [`NOT (${parts.join(" OR ")})`]; } function buildManualCatExclusionConditions(cats: IncomeCategory[], params: unknown[], allowedColumns: string[]): string[] { const rootManual = cats.filter(c => !c.autoDetected && !c.excluded && !c.treeId); if (rootManual.length === 0) return []; const parts: string[] = []; for (const mc of rootManual) { const cond = buildPathConditionSql([mc.rules], mc.ruleLogic || "and", params, allowedColumns); if (cond) parts.push(cond); } if (parts.length === 0) return []; return [`NOT (${parts.join(" OR ")})`]; } function buildSiblingExclusions(cats: IncomeCategory[], categoryId: string, params: unknown[], allowedColumns: string[] = ALLOWED_INCOME_COLUMNS()): string[] { const found = findCategoryById(cats, categoryId); if (!found) return []; let siblings: IncomeCategory[] = []; if (found.path.length === 1) { const targetTreeId = found.cat.treeId || "__manual__"; const sameTreeCats = cats.filter(c => (c.treeId || "__manual__") === targetTreeId); const idx = sameTreeCats.findIndex(c => c.id === categoryId); siblings = sameTreeCats.slice(idx + 1).filter(c => !c.excluded); } else { const parent = found.path[found.path.length - 2]; if (parent.children) { const idx = parent.children.findIndex(c => c.id === categoryId); siblings = parent.children.slice(idx + 1).filter(c => !c.excluded); } } const conditions: string[] = []; for (const sib of siblings) { const part = buildCatGroupCondition(sib, params, allowedColumns); if (part) conditions.push(`NOT ${part}`); } return conditions; } function validateRules(rules: any[], allowedColumns: string[], validOperators: string[]): string | null { for (const r of rules) { if (!allowedColumns.includes(r.column)) return `Недопустимый столбец: ${r.column}`; if (!validOperators.includes(r.operator)) return "Недопустимый оператор"; if (!r.value && r.value !== "0") return "Значение не может быть пустым"; } return null; } async function setupPgTrgm(): Promise { try { await query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); const incTable = getIncomeTable(); const expTable = getExpenseTable(); await Promise.all([ query(`CREATE INDEX IF NOT EXISTS "idx_trgm_inc_statya" ON "${incTable}" USING GIN (statya gin_trgm_ops)`).catch(() => {}), query(`CREATE INDEX IF NOT EXISTS "idx_trgm_inc_zakazchik" ON "${incTable}" USING GIN (zakazchik gin_trgm_ops)`).catch(() => {}), query(`CREATE INDEX IF NOT EXISTS "idx_trgm_exp_statya" ON "${expTable}" USING GIN (statya gin_trgm_ops)`).catch(() => {}), query(`CREATE INDEX IF NOT EXISTS "idx_trgm_exp_kontragent" ON "${expTable}" USING GIN (kontragent gin_trgm_ops)`).catch(() => {}), query(`CREATE INDEX IF NOT EXISTS "idx_trgm_exp_zakazchik" ON "${expTable}" USING GIN (zakazchik gin_trgm_ops)`).catch(() => {}), ]); console.log("[pg_trgm] Extension and GIN indexes ensured"); } catch (err) { console.warn("[pg_trgm] Could not set up trgm indexes (non-fatal):", (err as Error).message); } } async function initTagTables() { try { await query(`CREATE TABLE IF NOT EXISTS node_tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL )`); await query(`CREATE TABLE IF NOT EXISTS node_tag_assignments ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL, tag_id TEXT NOT NULL, context TEXT NOT NULL, UNIQUE(node_id, tag_id, context) )`); } catch (err) { console.warn("[tags] Could not create tag tables (non-fatal):", (err as Error).message); } } export async function registerRoutes(httpServer: Server, app: Express): Promise { try { await initOverrideTables(); await initTagTables(); await migrateJsonOverrides( CATEGORY_OVERRIDES_FILE, (id) => findCategoryById(loadCategories(), id), (id) => findCategoryById(loadExpenseCategories(), id) ); } catch (err) { console.error("[registerRoutes] initOverrideTables/migrate error:", err); } setupPgTrgm().catch(err => console.warn("[pg_trgm] background setup failed:", err)); app.post("/api/cache/invalidate", async (_req, res) => { try { cacheInvalidateAll(); invalidateCategoryMemCache(); await Promise.all([ runIncomeSyncCore().catch(err => console.error("[cache/invalidate] income sync:", err)), runExpenseSyncCore().catch(err => console.error("[cache/invalidate] expense sync:", err)), ]); await scheduleAndAwaitRebuild(); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при инвалидации кэша"); } }); app.get("/api/connection", (_req, res) => { const cfg = getConfig(); res.json({ host: cfg.host, port: cfg.port, database: cfg.database, user: cfg.user, password: "***" }); }); app.post("/api/connection/test", async (req, res) => { const parsed = dbConnectionSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: "Неверные данные" }); const result = await testConnection(parsed.data); res.json(result); }); app.post("/api/connection", async (req, res) => { const parsed = dbConnectionSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: "Неверные данные" }); const test = await testConnection(parsed.data); if (!test.ok) return res.status(400).json({ error: test.message }); setConfig(parsed.data); res.json({ ok: true }); }); app.get("/api/db/tables", async (_req, res) => { try { const result = await query(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`); const viewResult = await query(`SELECT table_name FROM information_schema.views WHERE table_schema = 'public' ORDER BY table_name`); const tables = result.rows.map((r: any) => r.table_name); const views = viewResult.rows.map((r: any) => r.table_name); res.json({ tables, views }); } catch (err) { handleApiError(res, err, "Не удалось получить список таблиц"); } }); app.get("/api/db/columns/:table", async (req, res) => { try { const tableName = req.params.table; if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) { return res.status(400).json({ error: "Некорректное имя таблицы" }); } const result = await query( `SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, [tableName] ); res.json(result.rows.map((r: any) => ({ name: r.column_name, type: r.data_type, nullable: r.is_nullable === "YES", }))); } catch (err) { handleApiError(res, err, "Не удалось получить столбцы таблицы"); } }); app.get("/api/table-config", (_req, res) => { res.json(loadTableConfig()); }); app.post("/api/table-config", async (req, res) => { try { const body = req.body; const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i; if (body.incomeTable && !VALID_IDENT.test(body.incomeTable)) { return res.status(400).json({ error: "Некорректное имя таблицы доходов" }); } if (body.expenseTable && !VALID_IDENT.test(body.expenseTable)) { return res.status(400).json({ error: "Некорректное имя таблицы расходов" }); } if (body.debtTable && !VALID_IDENT.test(body.debtTable)) { return res.status(400).json({ error: "Некорректное имя таблицы задолженности" }); } if (body.incomeColumns && Array.isArray(body.incomeColumns)) { if (!body.incomeColumns.every((c: string) => VALID_IDENT.test(c))) { return res.status(400).json({ error: "Некорректные имена столбцов доходов" }); } } if (body.expenseColumns && Array.isArray(body.expenseColumns)) { if (!body.expenseColumns.every((c: string) => VALID_IDENT.test(c))) { return res.status(400).json({ error: "Некорректные имена столбцов расходов" }); } } for (const tableField of ["incomeTable", "expenseTable", "debtTable"] as const) { if (body[tableField]) { try { const check = await query( `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 UNION SELECT 1 FROM information_schema.views WHERE table_schema = 'public' AND table_name = $1`, [body[tableField]] ); if (check.rows.length === 0) { return res.status(400).json({ error: `Таблица "${body[tableField]}" не найдена в БД` }); } } catch { return res.status(400).json({ error: `Не удалось проверить таблицу "${body[tableField]}"` }); } } } saveTableConfig(body); invalidateServerCache(); try { await recreateEffectiveViews(); } catch (viewErr) { console.error("[table-config] Failed to recreate effective views:", viewErr); } res.json({ ok: true, config: loadTableConfig() }); } catch (err) { res.status(500).json({ error: "Не удалось сохранить конфигурацию таблиц" }); } }); const TELEGRAM_CONFIG_PATH = path.join("data", "telegram-config.json"); function loadTelegramConfig(): { botToken: string; chats: { id: string; name: string; aiAccess?: boolean }[] } { try { if (fs.existsSync(TELEGRAM_CONFIG_PATH)) { return JSON.parse(fs.readFileSync(TELEGRAM_CONFIG_PATH, "utf-8")); } } catch {} return { botToken: "", chats: [] }; } const TABLE_SETTINGS_PATH = path.join(process.cwd(), "data", "table-settings.json"); function loadTableSettings(): Record { try { if (fs.existsSync(TABLE_SETTINGS_PATH)) { return JSON.parse(fs.readFileSync(TABLE_SETTINGS_PATH, "utf-8")); } } catch {} return {}; } app.get("/api/table-settings", (_req, res) => { res.json(loadTableSettings()); }); app.post("/api/table-settings", (req, res) => { try { const current = loadTableSettings(); const { section, visibleCols, colOrder } = req.body; if (!section || !["income", "expense"].includes(section)) { return res.status(400).json({ error: "section must be 'income' or 'expense'" }); } current[section] = { visibleCols: Array.isArray(visibleCols) ? visibleCols : current[section]?.visibleCols, colOrder: Array.isArray(colOrder) ? colOrder : current[section]?.colOrder, }; fs.writeFileSync(TABLE_SETTINGS_PATH, JSON.stringify(current, null, 2)); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка сохранения настроек" }); } }); app.get("/api/telegram/config", (_req, res) => { const cfg = loadTelegramConfig(); res.json({ botToken: "", hasToken: !!cfg.botToken, chats: cfg.chats }); }); app.post("/api/telegram/config", (req, res) => { try { const { botToken, chats } = req.body; if (typeof botToken !== "string") return res.status(400).json({ error: "botToken обязателен" }); if (!Array.isArray(chats)) return res.status(400).json({ error: "chats должен быть массивом" }); const existing = loadTelegramConfig(); const token = botToken === "" ? existing.botToken : botToken; const sanitized = chats.map((c: { id?: string; name?: string; aiAccess?: boolean }) => ({ id: String(c.id ?? "").trim(), name: String(c.name ?? "").trim(), aiAccess: !!c.aiAccess, })).filter((c: { id: string }) => c.id.length > 0); fs.writeFileSync(TELEGRAM_CONFIG_PATH, JSON.stringify({ botToken: token, chats: sanitized }, null, 2)); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Не удалось сохранить" }); } }); app.post("/api/telegram/test", async (req, res) => { try { const cfg = loadTelegramConfig(); if (!cfg.botToken) return res.status(400).json({ error: "Токен бота не настроен" }); if (cfg.chats.length === 0) return res.status(400).json({ error: "Нет настроенных чатов" }); const results: { chatId: string; name: string; ok: boolean; error?: string }[] = []; for (const chat of cfg.chats) { try { const resp = await fetch(`https://api.telegram.org/bot${cfg.botToken}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chat_id: chat.id, text: "привет" }), }); const json = await resp.json() as { ok: boolean; description?: string }; results.push({ chatId: chat.id, name: chat.name, ok: json.ok, error: json.ok ? undefined : (json.description ?? "Неизвестная ошибка") }); } catch (e: unknown) { results.push({ chatId: chat.id, name: chat.name, ok: false, error: e instanceof Error ? e.message : "Ошибка запроса" }); } } res.json({ results }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка отправки" }); } }); // --- AI Agent (конфиги и лог — чистое IO из ./ai-config, без Telegram-поллинга) --- app.get("/api/ai/config", (_req, res) => { const cfg = loadAiConfig(); res.json({ apiUrl: cfg.apiUrl, apiKey: "", hasApiKey: !!cfg.apiKey, model: cfg.model, systemPrompt: cfg.systemPrompt, enabled: cfg.enabled, name: cfg.name, }); }); app.post("/api/ai/config", (req, res) => { try { const { apiUrl, apiKey, model, systemPrompt, enabled, name } = req.body; const existing = loadAiConfig(); const newCfg = { apiUrl: typeof apiUrl === "string" && apiUrl.trim() ? apiUrl.trim() : existing.apiUrl, apiKey: typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : existing.apiKey, model: typeof model === "string" && model.trim() ? model.trim() : existing.model, systemPrompt: typeof systemPrompt === "string" ? systemPrompt : existing.systemPrompt, enabled: typeof enabled === "boolean" ? enabled : existing.enabled, name: typeof name === "string" && name.trim() ? name.trim() : existing.name, }; saveAiConfig(newCfg); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Не удалось сохранить" }); } }); app.get("/api/ai/planning-config", (_req, res) => { const cfg = loadAiPlanningConfig(); res.json({ apiUrl: cfg.apiUrl, apiKey: "", hasApiKey: !!cfg.apiKey, model: cfg.model, systemPrompt: cfg.systemPrompt, name: cfg.name, enabled: cfg.enabled, }); }); app.post("/api/ai/planning-config", (req, res) => { try { const { apiUrl, apiKey, model, systemPrompt, name, enabled } = req.body; const existing = loadAiPlanningConfig(); const newCfg = { apiUrl: typeof apiUrl === "string" && apiUrl.trim() ? apiUrl.trim() : existing.apiUrl, apiKey: typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : existing.apiKey, model: typeof model === "string" && model.trim() ? model.trim() : existing.model, systemPrompt: typeof systemPrompt === "string" ? systemPrompt : existing.systemPrompt, name: typeof name === "string" && name.trim() ? name.trim() : existing.name, enabled: typeof enabled === "boolean" ? enabled : existing.enabled, }; saveAiPlanningConfig(newCfg); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Не удалось сохранить" }); } }); app.post("/api/ai/planning-toggle", (req, res) => { try { const cfg = loadAiPlanningConfig(); const enabled = typeof req.body.enabled === "boolean" ? req.body.enabled : !cfg.enabled; cfg.enabled = enabled; saveAiPlanningConfig(cfg); res.json({ ok: true, enabled }); } catch (err) { console.error(err); res.status(500).json({ error: "Не удалось переключить" }); } }); app.get("/api/ai/log", (req, res) => { const days = Math.min(Math.max(parseInt(String(req.query.days)) || 90, 1), 365); res.json(getAiLog(days)); }); let modelsCache: { data: any[]; ts: number } | null = null; const MODELS_CACHE_TTL = 3600000; app.get("/api/ai/models", async (_req, res) => { try { if (modelsCache && Date.now() - modelsCache.ts < MODELS_CACHE_TTL) { return res.json(modelsCache.data); } const resp = await fetch("https://openrouter.ai/api/v1/models", { signal: AbortSignal.timeout(10000), }); if (!resp.ok) { return res.status(502).json({ error: "Не удалось загрузить модели" }); } const json = await resp.json() as any; const allModels = json.data || []; const toolModels = allModels .filter((m: any) => (m.supported_parameters || []).includes("tools")) .map((m: any) => ({ id: m.id, name: m.name, context_length: m.context_length, promptPrice: m.pricing?.prompt || "0", completionPrice: m.pricing?.completion || "0", })) .sort((a: any, b: any) => parseFloat(a.promptPrice) - parseFloat(b.promptPrice)); modelsCache = { data: toolModels, ts: Date.now() }; res.json(toolModels); } catch (err: any) { console.error("[AI Models]", err?.message); res.status(500).json({ error: "Ошибка загрузки моделей" }); } }); function buildIncludeCategoryConditions(cats: IncomeCategory[], selectedIds: string[], params: unknown[], allowedColumns: string[]): string[] { const flat = flattenCategories(cats); const selected = flat.filter(c => selectedIds.includes(c.id)); if (selected.length === 0) return ["FALSE"]; const parts: string[] = []; for (const cat of selected) { const part = buildCatGroupCondition(cat, params, allowedColumns); if (part) parts.push(part); } if (parts.length === 0) return ["FALSE"]; return [`(${parts.join(" OR ")})`]; } app.get("/api/di2/dashboard", async (req, res) => { try { const cacheKey = stableKey("dashboard", req.query as Record); const result = await cachedQuery(cacheKey, TTL_LONG, async () => { const maxPeriod = await getMaxPeriod(); const { dateFrom, dateTo, incomeCategories, expenseCategories } = req.query; const incParams: unknown[] = []; const incConditions: string[] = []; const dashCats = loadCategories(); incConditions.push(...buildGlobalExclusionConditions(dashCats, incParams)); if (dateFrom) { incParams.push(dateFrom); incConditions.push(`data::date >= $${incParams.length}::date`); } if (dateTo) { incParams.push(dateTo); incConditions.push(`data::date <= $${incParams.length}::date`); } if (incomeCategories) { try { const ids = JSON.parse(incomeCategories as string) as string[]; incConditions.push(...buildIncludeCategoryConditions(dashCats, ids, incParams, ALLOWED_INCOME_COLUMNS())); } catch {} } const incomeWhere = incConditions.length > 0 ? `WHERE ${incConditions.join(" AND ")}` : ""; const expParams: unknown[] = []; const expConditions: string[] = []; const expCats = loadExpenseCategories(); expConditions.push(...buildGlobalExclusionConditions(expCats, expParams, undefined, ALLOWED_EXPENSE_COLUMNS())); if (dateFrom) { expParams.push(dateFrom); expConditions.push(`data::date >= $${expParams.length}::date`); } if (dateTo) { expParams.push(dateTo); expConditions.push(`data::date <= $${expParams.length}::date`); } if (expenseCategories) { try { const ids = JSON.parse(expenseCategories as string) as string[]; expConditions.push(...buildIncludeCategoryConditions(expCats, ids, expParams, ALLOWED_EXPENSE_COLUMNS())); } catch {} } const expenseWhere = expConditions.length > 0 ? `WHERE ${expConditions.join(" AND ")}` : ""; const [incomeTotal, expenseTotal, debtStats, monthlyIncome, monthlyExpense, debtByClass, billedVsPaidRes] = await Promise.all([ query(`SELECT COALESCE(SUM(summa),0) as total, COUNT(*) as cnt FROM ${getIncomeEffectiveView()} ${incomeWhere}`, [...incParams]), query(`SELECT COALESCE(SUM(summadokumenta),0) as total, COUNT(*) as cnt FROM ${getExpenseEffectiveView()} ${expenseWhere}`, [...expParams]), query(` SELECT COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue, COUNT(DISTINCT kontragent) as counterparts FROM ${getDebtTable()} WHERE period = $1 `, [maxPeriod]), query(`SELECT DATE_TRUNC('month', data) as month, COALESCE(SUM(summa),0) as total FROM ${getIncomeEffectiveView()} ${incomeWhere} GROUP BY DATE_TRUNC('month', data) ORDER BY month DESC LIMIT 24`, [...incParams]), query(`SELECT DATE_TRUNC('month', data) as month, COALESCE(SUM(summadokumenta),0) as total FROM ${getExpenseEffectiveView()} ${expenseWhere} GROUP BY DATE_TRUNC('month', data) ORDER BY month DESC LIMIT 24`, [...expParams]), query(` SELECT CASE WHEN klassprosrochki IS NULL OR klassprosrochki = '0' THEN 'Не просрочено' ELSE klassprosrochki || ' дн.' END as class, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} WHERE period = $1 GROUP BY klassprosrochki ORDER BY total DESC `, [maxPeriod]), query(` SELECT TO_CHAR(period, 'YYYY-MM') as month, COALESCE(SUM(summadokumenta::numeric),0) as billed, COALESCE(SUM(summaoplacheno::numeric),0) as paid, COALESCE(SUM(summadolga::numeric),0) as debt FROM ${getDebtTable()} WHERE period >= (NOW() - INTERVAL '12 months')::date GROUP BY TO_CHAR(period, 'YYYY-MM') ORDER BY month `), ]); return { incomeTotal: Number(incomeTotal.rows[0].total), incomeCount: Number(incomeTotal.rows[0].cnt), expenseTotal: Number(expenseTotal.rows[0].total), expenseCount: Number(expenseTotal.rows[0].cnt), debtTotal: Number(debtStats.rows[0].total_debt), debtOverdue: Number(debtStats.rows[0].total_overdue), debtCounterparts: Number(debtStats.rows[0].counterparts), monthlyIncome: monthlyIncome.rows.map(r => ({ month: parseDate(r.month)!, total: Number(r.total) })).reverse(), monthlyExpense: monthlyExpense.rows.map(r => ({ month: parseDate(r.month)!, total: Number(r.total) })).reverse(), debtByClass: debtByClass.rows.map(r => ({ class: r.class, total: Number(r.total) })), billedVsPaid: billedVsPaidRes.rows.map(r => ({ month: r.month, billed: Number(r.billed), paid: Number(r.paid), debt: Number(r.debt) })), }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/income/column-values", async (req, res) => { try { const { column, search = "", context: contextStr = "" } = req.query; if (!column || !ALLOWED_INCOME_COLUMNS().includes(column as string)) { return res.status(400).json({ error: "Недопустимый столбец" }); } const cacheKey = `col-values:${column}:${search}:${contextStr}`; const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const col = column as string; const searchStr = String(search); const isDateOrNum = col === "data" || col === "summa"; const conditions: string[] = [`${isDateOrNum ? `CAST(${col} AS TEXT)` : col} IS NOT NULL`]; if (!isDateOrNum) conditions.push(`${col} != '0'`); const params: unknown[] = []; if (searchStr) { const colExpr = isDateOrNum ? `CAST(${col} AS TEXT)` : col; conditions.push(buildTokenILIKE(colExpr, searchStr, params)); } if (contextStr) { try { const ctx = JSON.parse(String(contextStr)) as Record; for (const [ctxCol, ctxVal] of Object.entries(ctx)) { if (!ctxVal) continue; if (ctxCol === "dateFrom") { params.push(ctxVal); conditions.push(`data::date >= $${params.length}::date`); } else if (ctxCol === "dateTo") { params.push(ctxVal); conditions.push(`data::date <= $${params.length}::date`); } else if (ALLOWED_INCOME_COLUMNS().includes(ctxCol) && ctxCol !== col) { const ctxIsDateOrNum = ctxCol === "data" || ctxCol === "summa"; const ctxExpr = ctxIsDateOrNum ? `CAST(${ctxCol} AS TEXT)` : ctxCol; conditions.push(buildTokenILIKE(ctxExpr, ctxVal, params)); } } } catch { /* ignore */ } } const where = `WHERE ${conditions.join(" AND ")}`; const selectExpr = isDateOrNum ? `CAST(${col} AS TEXT)` : col; const orderDir = isDateOrNum ? "DESC" : "ASC"; const result = await query( `SELECT DISTINCT ${selectExpr} as val FROM ${getIncomeEffectiveView()} ${where} ORDER BY val ${orderDir} LIMIT 20`, params ); return result.rows.map(r => r.val); }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/income/columns", async (_req, res) => { try { const result = await query(`SELECT * FROM ${getIncomeEffectiveView()} LIMIT 1`); const dbCols = result.fields.map((f: any) => f.name); res.json(dbCols); } catch (err) { handleApiError(res, err); } }); app.get("/api/income", async (req, res) => { try { const { filters: filtersStr, categoryId, excludeCategories, page = "1", pageSize = "50", sortBy, sortDir } = req.query; const p = Number(page); const ps = Math.min(Number(pageSize), 200); const offset = (p - 1) * ps; const conditions: string[] = []; const params: unknown[] = []; const incomeCats = loadCategories(); const incomeConfig = loadIncomeAutoDetectConfig(); const incomePrimaryTreeIds = new Set(incomeConfig.trees.filter(t => t.isPrimary).map(t => t.id)); let skipExclIds: string | Set | undefined; let rootManualRuleSets: IncomeCategory["rules"][] | undefined; if (categoryId) { const ancestorConditions = buildCategoryWithAncestorsConditions(incomeCats, categoryId as string, params); conditions.push(...ancestorConditions); const siblingExcl = buildSiblingExclusions(incomeCats, categoryId as string, params); conditions.push(...siblingExcl); const foundCat = findCategoryById(incomeCats, categoryId as string); if (foundCat) { skipExclIds = new Set(foundCat.path.map(c => c.id)); if (!foundCat.cat.autoDetected) { const ancestorAutoIds = new Set(foundCat.path.filter(c => c.autoDetected).map(c => c.id)); if (ancestorAutoIds.size > 0) { const autoExcl = buildAutoPathExclusionConditions(incomeCats, params, ALLOWED_INCOME_COLUMNS(), ancestorAutoIds); conditions.push(...autoExcl); } else { rootManualRuleSets = [foundCat.cat.rules]; if (foundCat.cat.treeId) { const autoExcl = buildAutoPathExclusionConditions(incomeCats, params, ALLOWED_INCOME_COLUMNS(), undefined, rootManualRuleSets); conditions.push(...autoExcl); } } } else { const manualExcl = buildManualCatExclusionConditions(incomeCats, params, ALLOWED_INCOME_COLUMNS()); conditions.push(...manualExcl); } } else { skipExclIds = categoryId as string; } } else if (excludeCategories === "true") { const excludeConditions = buildExcludeAllCategoriesConditions(incomeCats, params, ALLOWED_INCOME_COLUMNS(), incomePrimaryTreeIds); conditions.push(...excludeConditions); conditions.push('"_override_category_id" IS NULL'); } const globalExclusions = buildGlobalExclusionConditions(incomeCats, params, skipExclIds, ALLOWED_INCOME_COLUMNS(), excludeCategories === "true" ? incomePrimaryTreeIds : undefined, rootManualRuleSets); conditions.push(...globalExclusions); const filterConditions: string[] = []; if (filtersStr) { try { const filterObj = JSON.parse(String(filtersStr)) as Record; if (filterObj.dateFrom) { params.push(filterObj.dateFrom); filterConditions.push(`data::date >= $${params.length}::date`); } if (filterObj.dateTo) { params.push(filterObj.dateTo); filterConditions.push(`data::date <= $${params.length}::date`); } for (const [col, val] of Object.entries(filterObj)) { if (col === "dateFrom" || col === "dateTo") continue; if (ALLOWED_INCOME_COLUMNS().includes(col) && val) { const colExpr = (col === "summa" || col === "data") ? `CAST(${col} AS TEXT)` : col; filterConditions.push(buildTokenILIKE(colExpr, String(val), params)); } } } catch { /* ignore parse errors */ } } const whereParts: string[] = []; if (conditions.length > 0) { whereParts.push(...conditions); } whereParts.push(...filterConditions); const where = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : ""; const allIncomeCols = getAllIncomeColumns(); let orderBy = "ORDER BY data DESC"; if (sortBy && allIncomeCols.includes(sortBy as string)) { const dir = sortDir === "asc" ? "ASC" : "DESC"; orderBy = `ORDER BY "${sortBy as string}" ${dir} NULLS LAST`; } const incomeSelectCols = [...allIncomeCols.map(c => `"${c}"`), `"_override_category_id"`].join(", "); const [rows, total, sumResult] = await Promise.all([ query(` SELECT ${incomeSelectCols} FROM ${getIncomeEffectiveView()} ${where} ${orderBy} LIMIT ${ps} OFFSET ${offset} `, params), query(`SELECT COUNT(*) as cnt FROM ${getIncomeEffectiveView()} ${where}`, params), query(`SELECT COALESCE(SUM(summa),0) as total_sum FROM ${getIncomeEffectiveView()} ${where}`, params), ]); const totalRows = Number(total.rows[0].cnt); res.json({ rows: rows.rows.map(r => { const row: any = { ...r, data: parseDate(r.data) }; const ovCatId = r._override_category_id; if (ovCatId) { row.overrideCategoryId = ovCatId; const found = findCategoryById(incomeCats, ovCatId); row.overrideCategoryName = found ? found.cat.name : ovCatId; } delete row._override_category_id; return row; }), total: totalRows, page: p, pageSize: ps, totalPages: Math.ceil(totalRows / ps), totalSum: Number(sumResult.rows[0].total_sum), }); } catch (err) { handleApiError(res, err); } }); app.get("/api/income/filters", async (_req, res) => { try { const [statya, zakazchik, dochka] = await Promise.all([ query(`SELECT DISTINCT statya FROM ${getIncomeEffectiveView()} WHERE statya IS NOT NULL AND statya != '0' ORDER BY statya`), query(`SELECT DISTINCT zakazchik FROM ${getIncomeEffectiveView()} WHERE zakazchik IS NOT NULL ORDER BY zakazchik LIMIT 200`), query(`SELECT DISTINCT dochka FROM ${getIncomeEffectiveView()} WHERE dochka IS NOT NULL AND dochka != '0' ORDER BY dochka`), ]); res.json({ statya: statya.rows.map(r => r.statya), zakazchik: zakazchik.rows.map(r => r.zakazchik), dochka: dochka.rows.map(r => r.dochka), }); } catch (err) { handleApiError(res, err); } }); app.get("/api/income-categories", (_req, res) => { res.json(loadCategories()); }); const VALID_OPERATORS = ["equals", "not_equals", "contains", "greater_than", "less_than", "equal_number"]; app.post("/api/income-categories", (req, res) => { const { name, rules, excluded, parentId, ruleLogic } = req.body; if (!name || !Array.isArray(rules)) return res.status(400).json({ error: "Укажите name и rules" }); const err = validateRules(rules, ALLOWED_INCOME_COLUMNS(), VALID_OPERATORS); if (err) return res.status(400).json({ error: err }); const cats = loadCategories(); const newCat: IncomeCategory = { id: generateId(), name, rules, excluded: !!excluded }; if (ruleLogic === "or") newCat.ruleLogic = "or"; if (parentId) { const depth = getCategoryDepth(cats, parentId); if (depth >= 5) return res.status(400).json({ error: "Максимальная глубина вложенности — 5 уровней" }); if (!addChildToCategory(cats, parentId, newCat)) return res.status(404).json({ error: "Родительская категория не найдена" }); } else { cats.push(newCat); } saveCategories(cats); res.json(newCat); }); app.get("/api/income-categories/auto-detect/config", (_req, res) => { const config = loadIncomeAutoDetectConfig(); res.json({ trees: config.trees, allowedColumns: getIncomeAutoDetectColumns(), maxDepth: MAX_TREE_DEPTH }); }); app.post("/api/income-categories/auto-detect/config/trees", (req, res) => { const { name, columns, autoRefresh } = req.body; if (!name || typeof name !== "string") return res.status(400).json({ error: "Укажите название дерева" }); if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TREE_DEPTH) { return res.status(400).json({ error: `Укажите от 1 до ${MAX_TREE_DEPTH} колонок` }); } if (!columns.every((c: string) => getIncomeAutoDetectColumns().includes(c))) { return res.status(400).json({ error: "Недопустимая колонка" }); } const uniqueCols = [...new Set(columns as string[])]; const config = loadIncomeAutoDetectConfig(); const newTree: AutoDetectTree = { id: generateId(), name: name.trim(), columns: uniqueCols, autoRefresh: !!autoRefresh, isPrimary: true, }; config.trees.push(newTree); saveIncomeAutoDetectConfig(config); res.json(newTree); }); app.put("/api/income-categories/auto-detect/config/trees/:treeId", (req, res) => { const { name, columns, autoRefresh, filters, filterLogic, isPrimary, mode } = req.body; const config = loadIncomeAutoDetectConfig(); const tree = config.trees.find(t => t.id === req.params.treeId); if (!tree) return res.status(404).json({ error: "Дерево не найдено" }); if (name !== undefined) tree.name = String(name).trim(); if (isPrimary !== undefined) tree.isPrimary = !!isPrimary; if (mode !== undefined) tree.mode = mode === "merge" ? "merge" : "split"; if (columns !== undefined) { if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TREE_DEPTH) { return res.status(400).json({ error: `Укажите от 1 до ${MAX_TREE_DEPTH} колонок` }); } if (!columns.every((c: string) => getIncomeAutoDetectColumns().includes(c))) { return res.status(400).json({ error: "Недопустимая колонка" }); } tree.columns = [...new Set(columns as string[])]; } if (autoRefresh !== undefined) tree.autoRefresh = !!autoRefresh; if (filters !== undefined) { tree.filters = Array.isArray(filters) ? filters : []; } if (filterLogic !== undefined) { tree.filterLogic = filterLogic === "or" ? "or" : "and"; } saveIncomeAutoDetectConfig(config); if (isPrimary !== undefined) invalidateServerCache(); res.json(tree); }); app.delete("/api/income-categories/auto-detect/config/trees/:treeId", (_req, res) => { const config = loadIncomeAutoDetectConfig(); const idx = config.trees.findIndex(t => t.id === _req.params.treeId); if (idx < 0) return res.status(404).json({ error: "Дерево не найдено" }); config.trees.splice(idx, 1); saveIncomeAutoDetectConfig(config); res.json({ ok: true }); }); app.get("/api/income-categories/auto-detect/preview", async (req, res) => { try { const treeId = req.query.treeId as string | undefined; const config = loadIncomeAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildIncomeAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadCategories(); let newCount = 0; function countNew(newCats: IncomeCategory[], existingCats: IncomeCategory[]) { for (const nc of newCats) { if (!hasMatchingCategory(existingCats, nc.rules)) newCount++; if (nc.children) countNew(nc.children, existingCats); } } countNew(tree, existing); res.json({ tree, treeId: treeCfg.id, totalInTree: countCategories(tree), newCount, existingAutoCount: countAutoDetectedByTree(existing, treeCfg.id), }); } catch (err) { handleApiError(res, err, "Ошибка при анализе данных"); } }); app.post("/api/income-categories/auto-detect", async (req, res) => { try { const treeId = (req.query.treeId || req.body.treeId) as string | undefined; const config = loadIncomeAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildIncomeAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadCategories(); const added = mergeLevel(tree, existing); saveCategories(existing); res.json({ ok: true, added, total: countCategories(existing) }); } catch (err) { handleApiError(res, err, "Ошибка при создании категорий"); } }); app.post("/api/income-categories/auto-detect/sync", async (req, res) => { try { const treeId = (req.query.treeId || req.body.treeId) as string | undefined; const config = loadIncomeAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildIncomeAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadCategories(); const added = mergeLevel(tree, existing); saveCategories(existing); res.json({ ok: true, added, total: countCategories(existing) }); } catch (err) { handleApiError(res, err, "Ошибка при синхронизации"); } }); app.delete("/api/income-categories/auto-detect", (req, res) => { const treeId = req.query.treeId as string | undefined; const cats = loadCategories(); const removed = treeId ? countAutoDetectedByTree(cats, treeId) : countAutoDetected(cats); const cleaned = treeId ? removeAutoDetectedByTree(cats, treeId) : removeAutoDetected(cats); saveCategories(cleaned); res.json({ ok: true, removed }); }); app.put("/api/income-categories/:id", (req, res) => { const { name, rules, excluded, ruleLogic } = req.body; if (!name || !Array.isArray(rules)) return res.status(400).json({ error: "Укажите name и rules" }); const err = validateRules(rules, ALLOWED_INCOME_COLUMNS(), VALID_OPERATORS); if (err) return res.status(400).json({ error: err }); const cats = loadCategories(); const found = findCategoryById(cats, req.params.id); if (!found) return res.status(404).json({ error: "Не найдено" }); found.cat.name = name; found.cat.rules = rules; found.cat.ruleLogic = ruleLogic === "or" ? "or" : undefined; if (excluded !== undefined) found.cat.excluded = !!excluded; saveCategories(cats); res.json(found.cat); }); app.patch("/api/income-categories/:id/toggle-excluded", (req, res) => { const cats = loadCategories(); const found = findCategoryById(cats, req.params.id); if (!found) return res.status(404).json({ error: "Не найдено" }); found.cat.excluded = !found.cat.excluded; saveCategories(cats); res.json(found.cat); }); app.delete("/api/income-categories/:id", (req, res) => { const cats = loadCategories(); if (!findAndRemoveCategory(cats, req.params.id)) return res.status(404).json({ error: "Не найдено" }); saveCategories(cats); res.json({ ok: true }); }); app.post("/api/income-categories/:id/extract", (req, res) => { const cats = loadCategories(); const { name, includeChildren } = req.body as { name?: string; includeChildren?: boolean }; if (!name || !name.trim()) return res.status(400).json({ error: "Название обязательно" }); const found = findCategoryWithAncestors(cats, req.params.id); if (!found) return res.status(404).json({ error: "Категория не найдена" }); if (found.ancestors.length === 0) return res.status(400).json({ error: "Категория уже на верхнем уровне" }); const allRules = [...found.ancestors.flatMap(a => a.rules), ...found.cat.rules]; const seen = new Set(); const combinedRules = allRules.filter(r => { const key = `${r.column}|${r.operator}|${r.value}`; if (seen.has(key)) return false; seen.add(key); return true; }); const newCat: IncomeCategory = { id: generateId(), name: name.trim(), rules: combinedRules, autoDetected: false, }; if (includeChildren && found.cat.children && found.cat.children.length > 0) { newCat.children = deepCopyWithNewIdsManual(found.cat.children); } findAndUpdateCategory(cats, req.params.id, (c) => { c.excluded = true; }); cats.push(newCat); saveCategories(cats); res.json(newCat); }); async function autoSyncIncomeCategories(): Promise { await cachedQuery("__income_auto_sync__", TTL_LONG, async () => { try { const config = loadIncomeAutoDetectConfig(); const existing = loadCategories(); let totalAdded = 0; for (const treeCfg of config.trees) { const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildIncomeAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); totalAdded += mergeLevel(tree, existing); } if (totalAdded > 0) { saveCategories(existing); console.log(`[auto-sync] Added ${totalAdded} new income categories`); } } catch (err) { console.error("[auto-sync] Error syncing income categories:", err); } return true; }); } app.get("/api/income/tree-summary", async (req, res) => { try { await ensureTreesReady(); await autoSyncIncomeCategories(); const cacheKey = stableKey("income-tree-summary", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { dateFrom, dateTo } = req.query; const incomeCats = loadCategories(); const ruleColumns = new Set(); function collectRuleColumns(cats: IncomeCategory[]) { for (const cat of cats) { for (const rule of cat.rules) { if (ALLOWED_INCOME_COLUMNS().includes(rule.column) && rule.column !== "data" && rule.column !== "summa") { ruleColumns.add(rule.column); } } if (cat.children) collectRuleColumns(cat.children); } } collectRuleColumns(incomeCats); const groupCols = ruleColumns.size > 0 ? [...ruleColumns] : ["statya", "dochka", "vnuchka", "otvetstvenniipokontraktu"]; const params: unknown[] = []; const dateConds: string[] = []; if (dateFrom) { params.push(dateFrom); dateConds.push(`data::date >= $${params.length}::date`); } if (dateTo) { params.push(dateTo); dateConds.push(`data::date <= $${params.length}::date`); } const whereClause = dateConds.length > 0 ? `WHERE ${dateConds.join(" AND ")}` : ""; const selectCols = groupCols.join(", "); const aggResult = await query( `SELECT ${selectCols}, SUM(summa) as total, COUNT(*) as cnt, MIN(data) as min_date, MAX(data) as max_date FROM ${getIncomeEffectiveView()} ${whereClause} GROUP BY ${selectCols}`, params ); const aggRows: Record[] = aggResult.rows.map(r => { const row: Record = { total: Number(r.total), cnt: Number(r.cnt), min_date: r.min_date ?? null, max_date: r.max_date ?? null }; for (const col of groupCols) { row[col] = r[col] ?? ""; } return row; }); function rowMatchesRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; if (val === undefined) return false; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesRules(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (logic === "or") return rules.some(rule => rowMatchesRule(row, rule)); return rules.every(rule => rowMatchesRule(row, rule)); } const allFlat = flattenCategories(incomeCats); type AutoPath = { ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const autoPaths: AutoPath[] = []; type ExclPath = { id: string; ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const excludedPaths: ExclPath[] = []; function collectPaths(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][], inAutoTree = false) { for (const cat of cats) { const isAuto = !!cat.autoDetected; const nowInAutoTree = inAutoTree || isAuto; const path = [...ancestors, cat.rules]; if (cat.excluded) { if (!nowInAutoTree) { excludedPaths.push({ id: cat.id, ruleSets: path, logic: cat.ruleLogic || "and" }); } } else if (isAuto) { autoPaths.push({ ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) collectPaths(cat.children, path, nowInAutoTree); } } collectPaths(incomeCats, []); function rowMatchesAutoPath(row: Record, ap: AutoPath): boolean { for (let i = 0; i < ap.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ap.ruleSets[i])) return false; } const lastRules = ap.ruleSets[ap.ruleSets.length - 1]; return rowMatchesRules(row, lastRules, ap.logic); } function rowMatchesExclPath(row: Record, ep: ExclPath): boolean { for (let i = 0; i < ep.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ep.ruleSets[i])) return false; } return rowMatchesRules(row, ep.ruleSets[ep.ruleSets.length - 1], ep.logic); } function computeNodeFromAgg( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): { id: string; name: string; total: number; count: number; excluded?: boolean; autoDetected?: boolean; minDate?: string; maxDate?: string; children: any[] } { const allRuleSets = [...ancestorRuleSets, cat.rules]; const catLogic = cat.ruleLogic || "and"; const idx = siblings.findIndex(c => c.id === cat.id); const laterSibs = siblings.slice(idx + 1).filter(c => !c.excluded); let total = 0; let count = 0; let minDate: Date | null = null; let maxDate: Date | null = null; for (const row of aggRows) { const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue; if (cat.autoDetected && rootManualCats.length > 0 && rootManualCats.some(mc => rowMatchesRules(row, mc.rules, mc.ruleLogic || "and"))) continue; if (!cat.autoDetected && excludedPaths.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && rowMatchesExclPath(row, ep))) continue; if (!cat.autoDetected && !!cat.treeId && autoPaths.some(ap => rowMatchesAutoPath(row, ap) && !isRefinementOf(ap.ruleSets, allRuleSets))) continue; if (cat.children && cat.children.length > 0 && cat.children.some(child => child.rules.length > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue; total += row.total; count += row.cnt; if (row.min_date) { const d = new Date(row.min_date); if (!minDate || d < minDate) minDate = d; } if (row.max_date) { const d = new Date(row.max_date); if (!maxDate || d > maxDate) maxDate = d; } } const children: any[] = []; if (cat.children && cat.children.length > 0) { const childAncestorIds = new Set(ancestorIds); childAncestorIds.add(cat.id); for (const child of cat.children) { children.push(computeNodeFromAgg(child, cat.children, allRuleSets, childAncestorIds, rootManualCats)); } } return { id: cat.id, name: cat.name, total, count, excluded: cat.excluded || undefined, autoDetected: cat.autoDetected || undefined, minDate: minDate ? minDate.toISOString().slice(0, 10) : undefined, maxDate: maxDate ? maxDate.toISOString().slice(0, 10) : undefined, children, }; } const config = loadIncomeAutoDetectConfig(); const groupedByTree = new Map(); for (const cat of incomeCats) { const tid = cat.treeId || "__manual__"; if (!groupedByTree.has(tid)) groupedByTree.set(tid, []); groupedByTree.get(tid)!.push(cat); } const trees: { id: string; name: string; nodes: any[]; total: number; count: number; isPrimary?: boolean }[] = []; const configTreeMap = new Map(config.trees.map(t => [t.id, t])); const configTreeIds = new Set(config.trees.map((t: any) => t.id)); const primaryTreeIds = new Set(config.trees.filter(t => t.isPrimary).map(t => t.id)); const orphanCats: IncomeCategory[] = []; const manualCats: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") { manualCats.push(...cats); } else if (!configTreeIds.has(tid)) { orphanCats.push(...cats); } } const rootManualCatsForExclInc = manualCats.filter(c => !c.autoDetected && !c.excluded); type ManualWithAncestors = { cat: IncomeCategory; ancestorRuleSets: IncomeCategory["rules"][] }; function collectNestedManualCatsInc(cats: IncomeCategory[], ancestorRules: IncomeCategory["rules"][] = []): ManualWithAncestors[] { const result: ManualWithAncestors[] = []; for (const cat of cats) { if (!cat.autoDetected) result.push({ cat, ancestorRuleSets: ancestorRules }); const nextAncestors = [...ancestorRules, cat.rules]; if (cat.children) result.push(...collectNestedManualCatsInc(cat.children, nextAncestors)); } return result; } const rootManualIdsInc = new Set(manualCats.map(c => c.id)); const nestedManualWithAncestors: ManualWithAncestors[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__" || !configTreeIds.has(tid)) continue; for (const cat of cats) { const nested = collectNestedManualCatsInc(cat.children || [], [cat.rules]); for (const n of nested) { if (!rootManualIdsInc.has(n.cat.id)) { nestedManualWithAncestors.push(n); manualCats.push(n.cat); } } } } const nestedManualAncestorMap = new Map(); for (const n of nestedManualWithAncestors) { nestedManualAncestorMap.set(n.cat.id, n.ancestorRuleSets); } function nodeSubtreeTotal(n: any): number { if (n.excluded) { let t = 0; for (const ch of n.children) t += nodeSubtreeTotal(ch); return t; } let t = n.total; for (const ch of n.children) t += nodeSubtreeTotal(ch); return t; } function nodeSubtreeCount(n: any): number { if (n.excluded) { let c = 0; for (const ch of n.children) c += nodeSubtreeCount(ch); return c; } let c = n.count; for (const ch of n.children) c += nodeSubtreeCount(ch); return c; } const otherCats = [...manualCats, ...orphanCats]; if (otherCats.length > 0) { const nodes: any[] = []; for (const cat of otherCats) { const ancestorRules = nestedManualAncestorMap.get(cat.id) || []; nodes.push(computeNodeFromAgg(cat, otherCats, ancestorRules, new Set())); } const treeTotal = nodes.reduce((s: number, n: any) => s + nodeSubtreeTotal(n), 0); const treeCount = nodes.reduce((s: number, n: any) => s + nodeSubtreeCount(n), 0); trees.push({ id: "__manual__", name: "Ручные категории", nodes, total: treeTotal, count: treeCount, isPrimary: true }); } for (const treeCfg of config.trees) { const cats = groupedByTree.get(treeCfg.id) || []; const nodes: any[] = []; for (const cat of cats) { nodes.push(computeNodeFromAgg(cat, cats, [], new Set(), rootManualCatsForExclInc)); } const treeTotal = nodes.reduce((s: number, n: any) => s + nodeSubtreeTotal(n), 0); const treeCount = nodes.reduce((s: number, n: any) => s + nodeSubtreeCount(n), 0); trees.push({ id: treeCfg.id, name: treeCfg.name, nodes, total: treeTotal, count: treeCount, isPrimary: treeCfg.isPrimary || false }); } function isRowMatchedByTree(row: Record, treeCats: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][] = []): boolean { for (const cat of treeCats) { const catLogic = cat.ruleLogic || "and"; const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; return true; } return false; } let uncatTotal = 0; let uncatCount = 0; for (const row of aggRows) { let matched = false; for (const [tid, treeCats] of groupedByTree) { const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid); if (!isPrimary) continue; if (isRowMatchedByTree(row, treeCats)) { matched = true; break; } } if (!matched) { uncatTotal += row.total; uncatCount += row.cnt; } } let grandTotal = 0; let grandCount = 0; for (const row of aggRows) { grandTotal += row.total; grandCount += row.cnt; } return { trees, uncategorized: { total: uncatTotal, count: uncatCount }, grandTotal: { total: grandTotal, count: grandCount }, }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/income/tree-timeseries", async (req, res) => { try { await ensureTreesReady(); await autoSyncIncomeCategories(); const cacheKey = stableKey("income-tree-timeseries", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { dateFrom, dateTo, groupBy = "month", categoryId } = req.query; const validGroupBy = ["day", "month", "quarter", "year"]; const gb = validGroupBy.includes(groupBy as string) ? (groupBy as string) : "month"; const incomeCats = loadCategories(); const ruleColumns = new Set(); function collectRuleColumns(cats: IncomeCategory[]) { for (const cat of cats) { for (const rule of cat.rules) { if (ALLOWED_INCOME_COLUMNS().includes(rule.column) && rule.column !== "data" && rule.column !== "summa") { ruleColumns.add(rule.column); } } if (cat.children) collectRuleColumns(cat.children); } } collectRuleColumns(incomeCats); const groupCols = ruleColumns.size > 0 ? [...ruleColumns] : ["statya", "dochka", "vnuchka", "otvetstvenniipokontraktu"]; const params: unknown[] = []; const dateConds: string[] = []; if (dateFrom) { params.push(dateFrom); dateConds.push(`data::date >= $${params.length}::date`); } if (dateTo) { params.push(dateTo); dateConds.push(`data::date <= $${params.length}::date`); } const whereClause = dateConds.length > 0 ? `WHERE ${dateConds.join(" AND ")}` : ""; const selectCols = groupCols.join(", "); const aggResult = await query( `SELECT ${selectCols}, DATE_TRUNC('${gb}', data) as period, SUM(summa) as total, COUNT(*) as cnt FROM ${getIncomeEffectiveView()} ${whereClause} GROUP BY ${selectCols}, DATE_TRUNC('${gb}', data)`, params ); const aggRows: Record[] = aggResult.rows.map(r => { const row: Record = { total: Number(r.total), cnt: Number(r.cnt), period: r.period }; for (const col of groupCols) { row[col] = r[col] ?? ""; } return row; }); function rowMatchesRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; if (val === undefined) return false; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesRules(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (logic === "or") return rules.some(rule => rowMatchesRule(row, rule)); return rules.every(rule => rowMatchesRule(row, rule)); } const allFlat = flattenCategories(incomeCats); type AutoPathTS = { ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const autoPathsTS: AutoPathTS[] = []; type ExclPathTS = { id: string; ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const excludedPathsTS: ExclPathTS[] = []; function collectPathsTS(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][], inAutoTree = false) { for (const cat of cats) { const isAuto = !!cat.autoDetected; const nowInAutoTree = inAutoTree || isAuto; const path = [...ancestors, cat.rules]; if (cat.excluded) { if (!nowInAutoTree) { excludedPathsTS.push({ id: cat.id, ruleSets: path, logic: cat.ruleLogic || "and" }); } } else if (isAuto) { autoPathsTS.push({ ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) collectPathsTS(cat.children, path, nowInAutoTree); } } collectPathsTS(incomeCats, []); function rowMatchesAutoPathTS(row: Record, ap: AutoPathTS): boolean { for (let i = 0; i < ap.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ap.ruleSets[i])) return false; } return rowMatchesRules(row, ap.ruleSets[ap.ruleSets.length - 1], ap.logic); } const dataPeriods = new Set(); for (const row of aggRows) { if (row.period) dataPeriods.add(new Date(row.period).toISOString()); } const dataPeriodsSorted = [...dataPeriods].sort(); let sortedPeriods = dataPeriodsSorted; if (dataPeriodsSorted.length >= 2) { const filled: string[] = []; const minD = new Date(dataPeriodsSorted[0]); const maxD = new Date(dataPeriodsSorted[dataPeriodsSorted.length - 1]); const cur = new Date(minD); while (cur <= maxD) { filled.push(cur.toISOString()); if (gb === "day") cur.setUTCDate(cur.getUTCDate() + 1); else if (gb === "month") cur.setUTCMonth(cur.getUTCMonth() + 1); else if (gb === "quarter") cur.setUTCMonth(cur.getUTCMonth() + 3); else cur.setUTCFullYear(cur.getUTCFullYear() + 1); } sortedPeriods = filled; } function formatPeriodLabel(iso: string): string { const d = new Date(iso); if (gb === "day") return d.toISOString().slice(0, 10); if (gb === "month") return d.toISOString().slice(0, 7); if (gb === "quarter") { const q = Math.ceil((d.getMonth() + 1) / 3); return `${d.getFullYear()}-Q${q}`; } return `${d.getFullYear()}`; } const periodLabels = sortedPeriods.map(formatPeriodLabel); const periodIndexMap = new Map(sortedPeriods.map((p, i) => [p, i])); function computeCatTimeSeries( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): number[] { const allRuleSets = [...ancestorRuleSets, cat.rules]; const catLogic = cat.ruleLogic || "and"; const idx = siblings.findIndex(c => c.id === cat.id); const laterSibs = siblings.slice(idx + 1).filter(c => !c.excluded); const data = new Array(sortedPeriods.length).fill(0); for (const row of aggRows) { const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue; if (cat.autoDetected && rootManualCats.length > 0 && rootManualCats.some(mc => rowMatchesRules(row, mc.rules, mc.ruleLogic || "and"))) continue; if (!cat.autoDetected && excludedPathsTS.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) continue; if (!cat.autoDetected && !!cat.treeId && autoPathsTS.some(ap => rowMatchesAutoPathTS(row, ap) && !isRefinementOf(ap.ruleSets, allRuleSets))) continue; if (cat.children && cat.children.length > 0 && cat.children.some(child => child.rules.length > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue; const pi = periodIndexMap.get(new Date(row.period).toISOString()); if (pi !== undefined) data[pi] += row.total; } return data; } function subtreeTimeSeries( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): number[] { const own = computeCatTimeSeries(cat, siblings, ancestorRuleSets, ancestorIds, rootManualCats); if (!cat.children || cat.children.length === 0) return own; const allRuleSets = [...ancestorRuleSets, cat.rules]; const newAncestorIds = new Set([...ancestorIds, cat.id]); for (const child of cat.children) { if (child.excluded) continue; const childData = subtreeTimeSeries(child, cat.children, allRuleSets, newAncestorIds, rootManualCats); for (let i = 0; i < own.length; i++) own[i] += childData[i]; } return own; } const config = loadIncomeAutoDetectConfig(); const groupedByTree = new Map(); for (const cat of incomeCats) { const tid = cat.treeId || "__manual__"; if (!groupedByTree.has(tid)) groupedByTree.set(tid, []); groupedByTree.get(tid)!.push(cat); } const drillRootManualInc: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") drillRootManualInc.push(...cats.filter(c => !c.autoDetected && !c.excluded)); } if (categoryId && typeof categoryId === "string") { const found = findCategoryWithAncestors(incomeCats, categoryId); if (!found || !found.cat.children || found.cat.children.length === 0) { return { trees: [], drilldown: true }; } const ancestorRuleSets = [...found.ancestors.map(a => a.rules), found.cat.rules]; const drillAncestorIds = new Set([...found.ancestors.map(a => a.id), found.cat.id]); const children = found.cat.children; const isAutoTree = found.cat.autoDetected || found.ancestors.some(a => a.autoDetected); const drillManual = isAutoTree ? drillRootManualInc : []; const series: any[] = children.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeries(cat, children, ancestorRuleSets, drillAncestorIds, drillManual) })).filter(s => s.data.some(v => v !== 0)); const treeId = found.cat.treeId || found.ancestors[0]?.treeId || "__drilldown__"; return { trees: [{ id: treeId, name: found.cat.name, isPrimary: true, periods: periodLabels, series }], drilldown: true }; } const trees: any[] = []; const configTreeIds = new Set(config.trees.map((t: any) => t.id)); const orphanCats: IncomeCategory[] = []; const manualCats: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") manualCats.push(...cats); else if (!configTreeIds.has(tid)) orphanCats.push(...cats); } type ManualWithAncestorsTrInc = { cat: IncomeCategory; ancestorRuleSets: IncomeCategory["rules"][] }; function collectNestedManualTrInc(cats: IncomeCategory[], ancestorRules: IncomeCategory["rules"][] = []): ManualWithAncestorsTrInc[] { const result: ManualWithAncestorsTrInc[] = []; for (const cat of cats) { if (!cat.autoDetected) result.push({ cat, ancestorRuleSets: ancestorRules }); const nextAncestors = [...ancestorRules, cat.rules]; if (cat.children) result.push(...collectNestedManualTrInc(cat.children, nextAncestors)); } return result; } const rootManualCatsForExclTrInc = manualCats.filter(c => !c.autoDetected && !c.excluded); const rmIdsTrInc = new Set(manualCats.map(c => c.id)); const trendAncestorMapInc = new Map(); for (const [tid, cats] of groupedByTree) { if (tid === "__manual__" || !configTreeIds.has(tid)) continue; for (const cat of cats) { const nested = collectNestedManualTrInc(cat.children || [], [cat.rules]); for (const n of nested) { if (!rmIdsTrInc.has(n.cat.id)) { trendAncestorMapInc.set(n.cat.id, n.ancestorRuleSets); manualCats.push(n.cat); } } } } const otherCats = [...manualCats, ...orphanCats]; if (otherCats.length > 0) { const series = otherCats.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeries(cat, otherCats, trendAncestorMapInc.get(cat.id) || [], new Set()) })).filter(s => s.data.some(v => v !== 0)); trees.push({ id: "__manual__", name: "Ручные категории", isPrimary: true, periods: periodLabels, series }); } for (const treeCfg of config.trees) { const cats = groupedByTree.get(treeCfg.id) || []; const series = cats.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeries(cat, cats, [], new Set(), rootManualCatsForExclTrInc) })).filter(s => s.data.some(v => v !== 0)); trees.push({ id: treeCfg.id, name: treeCfg.name, isPrimary: treeCfg.isPrimary || false, periods: periodLabels, series }); } return { trees }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/expense-categories", (_req, res) => { res.json(loadExpenseCategories()); }); app.post("/api/expense-categories", (req, res) => { const { name, rules, excluded, parentId, ruleLogic } = req.body; if (!name || !Array.isArray(rules)) return res.status(400).json({ error: "Укажите name и rules" }); const err = validateRules(rules, ALLOWED_EXPENSE_COLUMNS(), VALID_OPERATORS); if (err) return res.status(400).json({ error: err }); const cats = loadExpenseCategories(); const newCat: IncomeCategory = { id: generateId(), name, rules, excluded: !!excluded }; if (ruleLogic === "or") newCat.ruleLogic = "or"; if (parentId) { const depth = getCategoryDepth(cats, parentId); if (depth >= 5) return res.status(400).json({ error: "Максимальная глубина вложенности — 5 уровней" }); if (!addChildToCategory(cats, parentId, newCat)) return res.status(404).json({ error: "Родительская категория не найдена" }); } else { cats.push(newCat); } saveExpenseCategories(cats); res.json(newCat); }); app.get("/api/expense-categories/auto-detect/config", (_req, res) => { const config = loadAutoDetectConfig(); res.json({ trees: config.trees, allowedColumns: getExpenseAutoDetectColumns(), maxDepth: MAX_TREE_DEPTH }); }); app.post("/api/expense-categories/auto-detect/config/trees", (req, res) => { const { name, columns, autoRefresh } = req.body; if (!name || typeof name !== "string") return res.status(400).json({ error: "Укажите название дерева" }); if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TREE_DEPTH) { return res.status(400).json({ error: `Укажите от 1 до ${MAX_TREE_DEPTH} колонок` }); } if (!columns.every((c: string) => getExpenseAutoDetectColumns().includes(c))) { return res.status(400).json({ error: "Недопустимая колонка" }); } const uniqueCols = [...new Set(columns as string[])]; const config = loadAutoDetectConfig(); const newTree: AutoDetectTree = { id: generateId(), name: name.trim(), columns: uniqueCols, autoRefresh: !!autoRefresh, isPrimary: true, }; config.trees.push(newTree); saveAutoDetectConfig(config); res.json(newTree); }); app.put("/api/expense-categories/auto-detect/config/trees/:treeId", (req, res) => { const { name, columns, autoRefresh, filters, filterLogic, isPrimary, mode } = req.body; const config = loadAutoDetectConfig(); const tree = config.trees.find(t => t.id === req.params.treeId); if (!tree) return res.status(404).json({ error: "Дерево не найдено" }); if (name !== undefined) tree.name = String(name).trim(); if (isPrimary !== undefined) tree.isPrimary = !!isPrimary; if (mode !== undefined) tree.mode = mode === "merge" ? "merge" : "split"; if (columns !== undefined) { if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TREE_DEPTH) { return res.status(400).json({ error: `Укажите от 1 до ${MAX_TREE_DEPTH} колонок` }); } if (!columns.every((c: string) => getExpenseAutoDetectColumns().includes(c))) { return res.status(400).json({ error: "Недопустимая колонка" }); } tree.columns = [...new Set(columns as string[])]; } if (autoRefresh !== undefined) tree.autoRefresh = !!autoRefresh; if (filters !== undefined) { tree.filters = Array.isArray(filters) ? filters : []; } if (filterLogic !== undefined) { tree.filterLogic = filterLogic === "or" ? "or" : "and"; } saveAutoDetectConfig(config); if (isPrimary !== undefined) invalidateServerCache(); res.json(tree); }); app.delete("/api/expense-categories/auto-detect/config/trees/:treeId", (_req, res) => { const config = loadAutoDetectConfig(); const idx = config.trees.findIndex(t => t.id === _req.params.treeId); if (idx < 0) return res.status(404).json({ error: "Дерево не найдено" }); config.trees.splice(idx, 1); saveAutoDetectConfig(config); res.json({ ok: true }); }); app.get("/api/expense-categories/auto-detect/preview", async (req, res) => { try { const treeId = req.query.treeId as string | undefined; const config = loadAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadExpenseCategories(); let newCount = 0; function countNew(newCats: IncomeCategory[], existingCats: IncomeCategory[]) { for (const nc of newCats) { if (!hasMatchingCategory(existingCats, nc.rules)) newCount++; if (nc.children) countNew(nc.children, existingCats); } } countNew(tree, existing); res.json({ tree, treeId: treeCfg.id, totalInTree: countCategories(tree), newCount, existingAutoCount: countAutoDetectedByTree(existing, treeCfg.id), }); } catch (err) { handleApiError(res, err, "Ошибка при анализе данных"); } }); app.post("/api/expense-categories/auto-detect", async (req, res) => { try { const treeId = (req.query.treeId || req.body.treeId) as string | undefined; const config = loadAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadExpenseCategories(); const added = mergeLevel(tree, existing); saveExpenseCategories(existing); res.json({ ok: true, added, total: countCategories(existing) }); } catch (err) { handleApiError(res, err, "Ошибка при создании категорий"); } }); app.post("/api/expense-categories/auto-detect/sync", async (req, res) => { try { const treeId = (req.query.treeId || req.body.treeId) as string | undefined; const config = loadAutoDetectConfig(); const treeCfg = treeId ? config.trees.find(t => t.id === treeId) : config.trees[0]; if (!treeCfg) return res.status(404).json({ error: "Дерево не найдено" }); const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); const existing = loadExpenseCategories(); const added = mergeLevel(tree, existing); saveExpenseCategories(existing); res.json({ ok: true, added, total: countCategories(existing) }); } catch (err) { handleApiError(res, err, "Ошибка при синхронизации"); } }); app.delete("/api/expense-categories/auto-detect", (req, res) => { const treeId = req.query.treeId as string | undefined; const cats = loadExpenseCategories(); const removed = treeId ? countAutoDetectedByTree(cats, treeId) : countAutoDetected(cats); const cleaned = treeId ? removeAutoDetectedByTree(cats, treeId) : removeAutoDetected(cats); saveExpenseCategories(cleaned); res.json({ ok: true, removed }); }); app.put("/api/expense-categories/:id", (req, res) => { const { name, rules, excluded, ruleLogic } = req.body; if (!name || !Array.isArray(rules)) return res.status(400).json({ error: "Укажите name и rules" }); const err = validateRules(rules, ALLOWED_EXPENSE_COLUMNS(), VALID_OPERATORS); if (err) return res.status(400).json({ error: err }); const cats = loadExpenseCategories(); const found = findCategoryById(cats, req.params.id); if (!found) return res.status(404).json({ error: "Не найдено" }); found.cat.name = name; found.cat.rules = rules; found.cat.ruleLogic = ruleLogic === "or" ? "or" : undefined; if (excluded !== undefined) found.cat.excluded = !!excluded; saveExpenseCategories(cats); res.json(found.cat); }); app.patch("/api/expense-categories/:id/toggle-excluded", (req, res) => { const cats = loadExpenseCategories(); const found = findCategoryById(cats, req.params.id); if (!found) return res.status(404).json({ error: "Не найдено" }); found.cat.excluded = !found.cat.excluded; saveExpenseCategories(cats); res.json(found.cat); }); app.delete("/api/expense-categories/:id", (req, res) => { const cats = loadExpenseCategories(); if (!findAndRemoveCategory(cats, req.params.id)) return res.status(404).json({ error: "Не найдено" }); saveExpenseCategories(cats); res.json({ ok: true }); }); app.post("/api/expense-categories/:id/extract", (req, res) => { const cats = loadExpenseCategories(); const { name, includeChildren } = req.body as { name?: string; includeChildren?: boolean }; if (!name || !name.trim()) return res.status(400).json({ error: "Название обязательно" }); const found = findCategoryWithAncestors(cats, req.params.id); if (!found) return res.status(404).json({ error: "Категория не найдена" }); if (found.ancestors.length === 0) return res.status(400).json({ error: "Категория уже на верхнем уровне" }); const allRules = [...found.ancestors.flatMap(a => a.rules), ...found.cat.rules]; const seen = new Set(); const combinedRules = allRules.filter(r => { const key = `${r.column}|${r.operator}|${r.value}`; if (seen.has(key)) return false; seen.add(key); return true; }); const newCat: IncomeCategory = { id: generateId(), name: name.trim(), rules: combinedRules, autoDetected: false, }; if (includeChildren && found.cat.children && found.cat.children.length > 0) { newCat.children = deepCopyWithNewIdsManual(found.cat.children); } findAndUpdateCategory(cats, req.params.id, (c) => { c.excluded = true; }); cats.push(newCat); saveExpenseCategories(cats); res.json(newCat); }); async function buildAutoDetectTree(cols: string[], treeId: string, filters?: TreeFilter[], filterLogic?: string): Promise { const selectCols = cols.map(c => `"${c}"`).join(", "); const orderCols = cols.map(c => `"${c}"`).join(", "); const params: unknown[] = []; const filterClause = buildFilterSQL(filters, filterLogic, params, getExpenseAutoDetectColumns()); const sql = `SELECT DISTINCT ${selectCols} FROM ${getExpenseEffectiveView()} WHERE "${cols[0]}" IS NOT NULL AND "${cols[0]}" != '0'${filterClause} ORDER BY ${orderCols}`; const result = await query(sql, params); type NestedMap = Map; const rootMap: NestedMap = new Map(); for (const row of result.rows) { const values: (string | null)[] = cols.map((c, i) => { const v = row[c] as string | null; return (v && v !== "0") ? v : (i === 0 ? null : null); }); if (!values[0]) continue; let currentMap = rootMap; for (let i = 0; i < values.length; i++) { const val = values[i]; if (!val) break; if (!currentMap.has(val)) currentMap.set(val, new Map()); currentMap = currentMap.get(val)!; } } function buildLevel(map: NestedMap, colIndex: number): IncomeCategory[] { const cats: IncomeCategory[] = []; for (const [name, childMap] of map) { const cat: IncomeCategory = { id: generateId(), name, rules: [{ column: cols[colIndex], operator: "equals", value: name }], autoDetected: true, treeId, }; if (colIndex < cols.length - 1 && childMap.size > 0) { cat.children = buildLevel(childMap, colIndex + 1); } cats.push(cat); } return cats; } return buildLevel(rootMap, 0); } function mergeLevel(newCats: IncomeCategory[], existingCats: IncomeCategory[]): number { let added = 0; for (const nc of newCats) { const match = existingCats.find(ec => rulesMatch(ec.rules, nc.rules)); if (match) { if (nc.children && nc.children.length > 0) { if (!match.children) match.children = []; added += mergeLevel(nc.children, match.children); } } else { existingCats.push(nc); added += countCategories([nc]); } } return added; } function hasMatchingCategory(existing: IncomeCategory[], rules: IncomeCategory["rules"]): boolean { for (const cat of existing) { if (rulesMatch(cat.rules, rules)) return true; if (cat.children && hasMatchingCategory(cat.children, rules)) return true; } return false; } function rulesMatch(a: IncomeCategory["rules"], b: IncomeCategory["rules"]): boolean { if (a.length !== b.length) return false; return a.every((r, i) => r.column === b[i].column && r.operator === b[i].operator && r.value === b[i].value); } function countCategories(cats: IncomeCategory[]): number { let count = 0; for (const cat of cats) { count++; if (cat.children) count += countCategories(cat.children); } return count; } function countAutoDetected(cats: IncomeCategory[]): number { let count = 0; for (const cat of cats) { if (cat.autoDetected) count++; if (cat.children) count += countAutoDetected(cat.children); } return count; } function countAutoDetectedByTree(cats: IncomeCategory[], treeId: string): number { let count = 0; for (const cat of cats) { if (cat.autoDetected && cat.treeId === treeId) count++; if (cat.children) count += countAutoDetectedByTree(cat.children, treeId); } return count; } function removeAutoDetected(cats: IncomeCategory[]): IncomeCategory[] { return cats.filter(c => { if (c.children) c.children = removeAutoDetected(c.children); return !c.autoDetected; }); } function removeAutoDetectedByTree(cats: IncomeCategory[], treeId: string): IncomeCategory[] { return cats.filter(c => { if (c.children) c.children = removeAutoDetectedByTree(c.children, treeId); return !(c.autoDetected && c.treeId === treeId); }); } app.get("/api/profitability", async (req, res) => { try { const { dateFrom, dateTo, search, page = "1", pageSize = "50" } = req.query; const p = Number(page); const ps = Math.min(Number(pageSize), 200); const offset = (p - 1) * ps; const rawCacheKey = stableKey("profitability:raw", { dateFrom: dateFrom ?? "", dateTo: dateTo ?? "" } as Record); const allRows = await cachedQuery<{ zakazchik: string; totalIncome: number; totalExpense: number; profit: number; margin: number; contractCount: number }[]>( rawCacheKey, TTL_LONG, async () => { const incConditions: string[] = ["zakazchik != '0'"]; const expConditions: string[] = ["zakazchik != '0'"]; const incParams: unknown[] = []; const expParams: unknown[] = []; const globalExcl = buildGlobalExclusionConditions(loadCategories(), incParams); incConditions.push(...globalExcl); if (dateFrom) { incParams.push(dateFrom); incConditions.push(`data >= $${incParams.length}`); expParams.push(dateFrom); expConditions.push(`data >= $${expParams.length}`); } if (dateTo) { incParams.push(dateTo); incConditions.push(`data <= $${incParams.length}`); expParams.push(dateTo); expConditions.push(`data <= $${expParams.length}`); } const incWhere = `WHERE ${incConditions.join(" AND ")}`; const expWhere = `WHERE ${expConditions.join(" AND ")}`; const [incomeData, expenseData] = await Promise.all([ query(` SELECT zakazchik, COALESCE(SUM(summa),0) as total, COUNT(DISTINCT dogovorzakazchika) as contracts FROM ${getIncomeEffectiveView()} ${incWhere} GROUP BY zakazchik `, incParams), query(` SELECT zakazchik, COALESCE(SUM(summadokumenta),0) as total FROM ${getExpenseEffectiveView()} ${expWhere} GROUP BY zakazchik `, expParams), ]); const expMap = new Map(); expenseData.rows.forEach((r: any) => expMap.set(r.zakazchik, Number(r.total))); const incSet = new Set(); const combined: { zakazchik: string; totalIncome: number; totalExpense: number; profit: number; margin: number; contractCount: number }[] = []; incomeData.rows.forEach((r: any) => { incSet.add(r.zakazchik); const totalIncome = Number(r.total); const totalExpense = expMap.get(r.zakazchik) ?? 0; const profit = totalIncome - totalExpense; combined.push({ zakazchik: r.zakazchik, totalIncome, totalExpense, profit, margin: totalIncome > 0 ? Math.round((profit / totalIncome) * 10000) / 100 : 0, contractCount: Number(r.contracts), }); }); expenseData.rows.forEach((r: any) => { if (!incSet.has(r.zakazchik)) { const totalExpense = Number(r.total); combined.push({ zakazchik: r.zakazchik, totalIncome: 0, totalExpense, profit: -totalExpense, margin: 0, contractCount: 0, }); } }); combined.sort((a, b) => b.totalIncome - a.totalIncome); return combined; } ); let rows = allRows; if (search) { const s = String(search).toLowerCase(); rows = rows.filter(r => r.zakazchik.toLowerCase().includes(s)); } const total = rows.length; const summary = { totalIncome: rows.reduce((s, r) => s + r.totalIncome, 0), totalExpense: rows.reduce((s, r) => s + r.totalExpense, 0), totalProfit: rows.reduce((s, r) => s + r.profit, 0), counterpartCount: total, }; const paged = rows.slice(offset, offset + ps); res.json({ rows: paged, total, page: p, pageSize: ps, totalPages: Math.ceil(total / ps), summary, }); } catch (err) { handleApiError(res, err); } }); app.get("/api/profitability/monthly", async (req, res) => { try { const { dateFrom, dateTo, zakazchik } = req.query; const incConditions: string[] = ["zakazchik != '0'"]; const expConditions: string[] = ["zakazchik != '0'"]; const incParams: unknown[] = []; const expParams: unknown[] = []; const mExcl = buildGlobalExclusionConditions(loadCategories(), incParams); incConditions.push(...mExcl); if (dateFrom) { incParams.push(dateFrom); incConditions.push(`data >= $${incParams.length}`); expParams.push(dateFrom); expConditions.push(`data >= $${expParams.length}`); } if (dateTo) { incParams.push(dateTo); incConditions.push(`data <= $${incParams.length}`); expParams.push(dateTo); expConditions.push(`data <= $${expParams.length}`); } if (zakazchik) { incParams.push(zakazchik); incConditions.push(`zakazchik = $${incParams.length}`); expParams.push(zakazchik); expConditions.push(`zakazchik = $${expParams.length}`); } const incWhere = `WHERE ${incConditions.join(" AND ")}`; const expWhere = `WHERE ${expConditions.join(" AND ")}`; const [incomeMonthly, expenseMonthly] = await Promise.all([ query(` SELECT DATE_TRUNC('month', data) as month, COALESCE(SUM(summa),0) as total FROM ${getIncomeEffectiveView()} ${incWhere} GROUP BY DATE_TRUNC('month', data) ORDER BY month `, incParams), query(` SELECT DATE_TRUNC('month', data) as month, COALESCE(SUM(summadokumenta),0) as total FROM ${getExpenseEffectiveView()} ${expWhere} GROUP BY DATE_TRUNC('month', data) ORDER BY month `, expParams), ]); const monthMap = new Map(); incomeMonthly.rows.forEach((r: any) => { const m = parseDate(r.month)!; monthMap.set(m, { income: Number(r.total), expense: 0 }); }); expenseMonthly.rows.forEach((r: any) => { const m = parseDate(r.month)!; const existing = monthMap.get(m) ?? { income: 0, expense: 0 }; existing.expense = Number(r.total); monthMap.set(m, existing); }); const rows = Array.from(monthMap.entries()) .map(([month, data]) => ({ month, income: data.income, expense: data.expense, profit: data.income - data.expense, })) .sort((a, b) => a.month.localeCompare(b.month)); res.json(rows); } catch (err) { handleApiError(res, err); } }); app.get("/api/profitability/:zakazchik/contracts", async (req, res) => { try { const { dateFrom, dateTo } = req.query; const zakazchik = req.params.zakazchik; const incConditions: string[] = ["zakazchik = $1", "dogovorzakazchika != '0'"]; const expConditions: string[] = ["zakazchik = $1", "dogovorzakazchika != '0'"]; const incParams: unknown[] = [zakazchik]; const expParams: unknown[] = [zakazchik]; if (dateFrom) { incParams.push(dateFrom); incConditions.push(`data >= $${incParams.length}`); expParams.push(dateFrom); expConditions.push(`data >= $${expParams.length}`); } if (dateTo) { incParams.push(dateTo); incConditions.push(`data <= $${incParams.length}`); expParams.push(dateTo); expConditions.push(`data <= $${expParams.length}`); } const incWhere = `WHERE ${incConditions.join(" AND ")}`; const expWhere = `WHERE ${expConditions.join(" AND ")}`; const [incomeContracts, expenseContracts] = await Promise.all([ query(` SELECT dogovorzakazchika, COALESCE(SUM(summa),0) as total FROM ${getIncomeEffectiveView()} ${incWhere} GROUP BY dogovorzakazchika ORDER BY total DESC `, incParams), query(` SELECT dogovorzakazchika, COALESCE(SUM(summadokumenta),0) as total FROM ${getExpenseEffectiveView()} ${expWhere} GROUP BY dogovorzakazchika `, expParams), ]); const expMap = new Map(); expenseContracts.rows.forEach((r: any) => expMap.set(r.dogovorzakazchika, Number(r.total))); const contractSet = new Set(); const rows: any[] = []; incomeContracts.rows.forEach((r: any) => { contractSet.add(r.dogovorzakazchika); const totalIncome = Number(r.total); const totalExpense = expMap.get(r.dogovorzakazchika) ?? 0; rows.push({ dogovorzakazchika: r.dogovorzakazchika, totalIncome, totalExpense, profit: totalIncome - totalExpense, }); }); expenseContracts.rows.forEach((r: any) => { if (!contractSet.has(r.dogovorzakazchika)) { rows.push({ dogovorzakazchika: r.dogovorzakazchika, totalIncome: 0, totalExpense: Number(r.total), profit: -Number(r.total), }); } }); rows.sort((a, b) => b.totalIncome - a.totalIncome); res.json(rows); } catch (err) { handleApiError(res, err); } }); app.get("/api/expenses/column-values", async (req, res) => { try { const { column, search = "", context: contextStr = "" } = req.query; if (!column || !ALLOWED_EXPENSE_COLUMNS().includes(column as string)) { return res.status(400).json({ error: "Недопустимый столбец" }); } const cacheKey = `exp-col-values:${column}:${search}:${contextStr}`; const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const col = column as string; const searchStr = String(search); const isDateOrNum = col === "data" || col === "summadokumenta"; const conditions: string[] = [`${isDateOrNum ? `CAST(${col} AS TEXT)` : col} IS NOT NULL`]; if (!isDateOrNum) conditions.push(`${col} != '0'`); const params: unknown[] = []; if (searchStr) { const colExpr = isDateOrNum ? `CAST(${col} AS TEXT)` : col; conditions.push(buildTokenILIKE(colExpr, searchStr, params)); } if (contextStr) { try { const ctx = JSON.parse(String(contextStr)) as Record; for (const [ctxCol, ctxVal] of Object.entries(ctx)) { if (!ctxVal) continue; if (ctxCol === "dateFrom") { params.push(ctxVal); conditions.push(`data::date >= $${params.length}::date`); } else if (ctxCol === "dateTo") { params.push(ctxVal); conditions.push(`data::date <= $${params.length}::date`); } else if (ALLOWED_EXPENSE_COLUMNS().includes(ctxCol) && ctxCol !== col) { const ctxIsDateOrNum = ctxCol === "data" || ctxCol === "summadokumenta"; const ctxExpr = ctxIsDateOrNum ? `CAST(${ctxCol} AS TEXT)` : ctxCol; conditions.push(buildTokenILIKE(ctxExpr, ctxVal, params)); } } } catch { /* ignore */ } } const where = `WHERE ${conditions.join(" AND ")}`; const selectExpr = isDateOrNum ? `CAST(${col} AS TEXT)` : col; const orderDir = isDateOrNum ? "DESC" : "ASC"; const result = await query( `SELECT DISTINCT ${selectExpr} as val FROM ${getExpenseEffectiveView()} ${where} ORDER BY val ${orderDir} LIMIT 20`, params ); return result.rows.map(r => r.val); }); res.json(result); } catch (err) { handleApiError(res, err); } }); async function runIncomeSyncCore(): Promise { const config = loadIncomeAutoDetectConfig(); const existing = loadCategories(); let totalAdded = 0; for (const treeCfg of config.trees) { const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildIncomeAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); totalAdded += mergeLevel(tree, existing); } if (totalAdded > 0) { saveCategories(existing); console.log(`[auto-sync] Added ${totalAdded} new income categories`); } return totalAdded; } async function runExpenseSyncCore(): Promise { const config = loadAutoDetectConfig(); const existing = loadExpenseCategories(); let totalAdded = 0; for (const treeCfg of config.trees) { const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); totalAdded += mergeLevel(tree, existing); } if (totalAdded > 0) { saveExpenseCategories(existing); console.log(`[auto-sync] Added ${totalAdded} new expense categories`); } return totalAdded; } async function autoSyncExpenseCategories(): Promise { await cachedQuery("__expense_auto_sync__", TTL_LONG, async () => { try { const config = loadAutoDetectConfig(); const existing = loadExpenseCategories(); let totalAdded = 0; for (const treeCfg of config.trees) { const tree = treeCfg.mode === "merge" ? buildMergedCategory(treeCfg) : await buildAutoDetectTree(treeCfg.columns, treeCfg.id, treeCfg.filters, treeCfg.filterLogic); totalAdded += mergeLevel(tree, existing); } if (totalAdded > 0) { saveExpenseCategories(existing); console.log(`[auto-sync] Added ${totalAdded} new expense categories`); } } catch (err) { console.error("[auto-sync] Error syncing expense categories:", err); } return true; }); } app.get("/api/admin/rebuild-status", (_req, res) => { res.json({ inProgress: treeRebuildInProgress, pendingRebuild: treeRebuildPending, lastCompletedAt: treeRebuildLastCompletedAt ? treeRebuildLastCompletedAt.toISOString() : null, }); }); app.post("/api/refresh-all", async (_req, res) => { try { cacheInvalidateAll(); const [addedIncome, addedExpense] = await Promise.all([ runIncomeSyncCore().catch(err => { console.error("[refresh-all] income sync error:", err); return 0; }), runExpenseSyncCore().catch(err => { console.error("[refresh-all] expense sync error:", err); return 0; }), ]); await scheduleAndAwaitRebuild(); res.json({ ok: true, addedIncome, addedExpense, rebuiltAt: treeRebuildLastCompletedAt ? treeRebuildLastCompletedAt.toISOString() : null, }); } catch (err) { handleApiError(res, err, "Ошибка при полном обновлении данных"); } }); app.get("/api/expenses/tree-summary", async (req, res) => { try { await ensureTreesReady(); await autoSyncExpenseCategories(); const cacheKey = stableKey("expense-tree-summary", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { dateFrom, dateTo } = req.query; const expenseCats = loadExpenseCategories(); const ruleColumns = new Set(); function collectRuleColumns(cats: IncomeCategory[]) { for (const cat of cats) { for (const rule of cat.rules) { if (ALLOWED_EXPENSE_COLUMNS().includes(rule.column) && rule.column !== "data" && rule.column !== "summadokumenta") { ruleColumns.add(rule.column); } } if (cat.children) collectRuleColumns(cat.children); } } collectRuleColumns(expenseCats); const groupCols = ruleColumns.size > 0 ? [...ruleColumns] : ["statya", "dochka", "vnuchka", "otvetstvenniipokontraktu"]; const params: unknown[] = []; const dateConds: string[] = []; if (dateFrom) { params.push(dateFrom); dateConds.push(`data::date >= $${params.length}::date`); } if (dateTo) { params.push(dateTo); dateConds.push(`data::date <= $${params.length}::date`); } const whereClause = dateConds.length > 0 ? `WHERE ${dateConds.join(" AND ")}` : ""; const selectCols = groupCols.join(", "); const aggResult = await query( `SELECT ${selectCols}, SUM(summadokumenta) as total, COUNT(*) as cnt, MIN(data) as min_date, MAX(data) as max_date FROM ${getExpenseEffectiveView()} ${whereClause} GROUP BY ${selectCols}`, params ); const aggRows: Record[] = aggResult.rows.map(r => { const row: Record = { total: Number(r.total), cnt: Number(r.cnt), min_date: r.min_date ?? null, max_date: r.max_date ?? null }; for (const col of groupCols) { row[col] = r[col] ?? ""; } return row; }); function rowMatchesRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; if (val === undefined) return false; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesRules(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (logic === "or") return rules.some(rule => rowMatchesRule(row, rule)); return rules.every(rule => rowMatchesRule(row, rule)); } const allFlat = flattenCategories(expenseCats); type AutoPathExp = { ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const autoPathsExp: AutoPathExp[] = []; type ExclPathExp = { id: string; ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const excludedPathsExp: ExclPathExp[] = []; function collectPathsExp(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][], inAutoTree = false) { for (const cat of cats) { const isAuto = !!cat.autoDetected; const nowInAutoTree = inAutoTree || isAuto; const path = [...ancestors, cat.rules]; if (cat.excluded) { if (!nowInAutoTree) { excludedPathsExp.push({ id: cat.id, ruleSets: path, logic: cat.ruleLogic || "and" }); } } else if (isAuto) { autoPathsExp.push({ ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) collectPathsExp(cat.children, path, nowInAutoTree); } } collectPathsExp(expenseCats, []); function rowMatchesAutoPathExp(row: Record, ap: AutoPathExp): boolean { for (let i = 0; i < ap.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ap.ruleSets[i])) return false; } const lastRules = ap.ruleSets[ap.ruleSets.length - 1]; return rowMatchesRules(row, lastRules, ap.logic); } function computeNodeFromAgg( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): { id: string; name: string; total: number; count: number; excluded?: boolean; autoDetected?: boolean; minDate?: string; maxDate?: string; children: any[] } { const allRuleSets = [...ancestorRuleSets, cat.rules]; const catLogic = cat.ruleLogic || "and"; const idx = siblings.findIndex(c => c.id === cat.id); const laterSibs = siblings.slice(idx + 1).filter(c => !c.excluded); let total = 0; let count = 0; let minDate: Date | null = null; let maxDate: Date | null = null; for (const row of aggRows) { const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue; if (cat.autoDetected && rootManualCats.length > 0 && rootManualCats.some(mc => rowMatchesRules(row, mc.rules, mc.ruleLogic || "and"))) continue; if (!cat.autoDetected && excludedPathsExp.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) continue; if (!cat.autoDetected && !!cat.treeId && autoPathsExp.some(ap => rowMatchesAutoPathExp(row, ap) && !isRefinementOf(ap.ruleSets, allRuleSets))) continue; if (cat.children && cat.children.length > 0 && cat.children.some(child => child.rules.length > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue; total += row.total; count += row.cnt; if (row.min_date) { const d = new Date(row.min_date); if (!minDate || d < minDate) minDate = d; } if (row.max_date) { const d = new Date(row.max_date); if (!maxDate || d > maxDate) maxDate = d; } } const children: any[] = []; if (cat.children && cat.children.length > 0) { const childAncestorIds = new Set(ancestorIds); childAncestorIds.add(cat.id); for (const child of cat.children) { children.push(computeNodeFromAgg(child, cat.children, allRuleSets, childAncestorIds, rootManualCats)); } } return { id: cat.id, name: cat.name, total, count, excluded: cat.excluded || undefined, autoDetected: cat.autoDetected || undefined, minDate: minDate ? minDate.toISOString().slice(0, 10) : undefined, maxDate: maxDate ? maxDate.toISOString().slice(0, 10) : undefined, children, }; } const config = loadAutoDetectConfig(); const groupedByTree = new Map(); for (const cat of expenseCats) { const tid = cat.treeId || "__manual__"; if (!groupedByTree.has(tid)) groupedByTree.set(tid, []); groupedByTree.get(tid)!.push(cat); } const trees: { id: string; name: string; nodes: any[]; total: number; count: number; isPrimary?: boolean }[] = []; const configTreeIds = new Set(config.trees.map((t: any) => t.id)); const primaryTreeIds = new Set(config.trees.filter(t => t.isPrimary).map(t => t.id)); const orphanCats: IncomeCategory[] = []; const manualCats: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") { manualCats.push(...cats); } else if (!configTreeIds.has(tid)) { orphanCats.push(...cats); } } const rootManualCatsForExclExp = manualCats.filter(c => !c.autoDetected && !c.excluded); type ManualWithAncestorsExp = { cat: IncomeCategory; ancestorRuleSets: IncomeCategory["rules"][] }; function collectNestedManualCatsExp(cats: IncomeCategory[], ancestorRules: IncomeCategory["rules"][] = []): ManualWithAncestorsExp[] { const result: ManualWithAncestorsExp[] = []; for (const cat of cats) { if (!cat.autoDetected) result.push({ cat, ancestorRuleSets: ancestorRules }); const nextAncestors = [...ancestorRules, cat.rules]; if (cat.children) result.push(...collectNestedManualCatsExp(cat.children, nextAncestors)); } return result; } const rootManualIds = new Set(manualCats.map(c => c.id)); const nestedManualWithAncestorsExp: ManualWithAncestorsExp[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__" || !configTreeIds.has(tid)) continue; for (const cat of cats) { const nested = collectNestedManualCatsExp(cat.children || [], [cat.rules]); for (const n of nested) { if (!rootManualIds.has(n.cat.id)) { nestedManualWithAncestorsExp.push(n); manualCats.push(n.cat); } } } } const nestedManualAncestorMapExp = new Map(); for (const n of nestedManualWithAncestorsExp) { nestedManualAncestorMapExp.set(n.cat.id, n.ancestorRuleSets); } function nodeSubtreeTotalExp(n: any): number { if (n.excluded) { let t = 0; for (const ch of n.children) t += nodeSubtreeTotalExp(ch); return t; } let t = n.total; for (const ch of n.children) t += nodeSubtreeTotalExp(ch); return t; } function nodeSubtreeCountExp(n: any): number { if (n.excluded) { let c = 0; for (const ch of n.children) c += nodeSubtreeCountExp(ch); return c; } let c = n.count; for (const ch of n.children) c += nodeSubtreeCountExp(ch); return c; } const otherCats = [...manualCats, ...orphanCats]; if (otherCats.length > 0) { const nodes: any[] = []; for (const cat of otherCats) { const ancestorRules = nestedManualAncestorMapExp.get(cat.id) || []; nodes.push(computeNodeFromAgg(cat, otherCats, ancestorRules, new Set())); } const treeTotal = nodes.reduce((s, n) => s + nodeSubtreeTotalExp(n), 0); const treeCount = nodes.reduce((s, n) => s + nodeSubtreeCountExp(n), 0); trees.push({ id: "__manual__", name: "Ручные категории", nodes, total: treeTotal, count: treeCount, isPrimary: true }); } for (const treeCfg of config.trees) { const cats = groupedByTree.get(treeCfg.id) || []; const nodes: any[] = []; for (const cat of cats) { nodes.push(computeNodeFromAgg(cat, cats, [], new Set(), rootManualCatsForExclExp)); } const treeTotal = nodes.reduce((s, n) => s + nodeSubtreeTotalExp(n), 0); const treeCount = nodes.reduce((s, n) => s + nodeSubtreeCountExp(n), 0); trees.push({ id: treeCfg.id, name: treeCfg.name, nodes, total: treeTotal, count: treeCount, isPrimary: treeCfg.isPrimary || false }); } function isRowMatchedByTreeExp(row: Record, treeCats: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][] = []): boolean { for (const cat of treeCats) { const catLogic = cat.ruleLogic || "and"; const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; return true; } return false; } let uncatTotal = 0; let uncatCount = 0; for (const row of aggRows) { let matched = false; for (const [tid, treeCats] of groupedByTree) { const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid); if (!isPrimary) continue; if (isRowMatchedByTreeExp(row, treeCats)) { matched = true; break; } } if (!matched) { uncatTotal += row.total; uncatCount += row.cnt; } } let grandTotal = 0; let grandCount = 0; for (const row of aggRows) { grandTotal += row.total; grandCount += row.cnt; } return { trees, uncategorized: { total: uncatTotal, count: uncatCount }, grandTotal: { total: grandTotal, count: grandCount }, }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/heatmap", async (req, res) => { try { const { type = "income", dateFrom, dateTo } = req.query; if (type !== "income" && type !== "expense") return res.status(400).json({ error: "type must be income or expense" }); const isIncome = type === "income"; if (isIncome) await autoSyncIncomeCategories(); else await autoSyncExpenseCategories(); const cacheKey = stableKey(`heatmap-${type}`, req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const cats = isIncome ? loadCategories() : loadExpenseCategories(); const allowedCols = isIncome ? ALLOWED_INCOME_COLUMNS() : ALLOWED_EXPENSE_COLUMNS(); const table = isIncome ? getIncomeEffectiveView() : getExpenseEffectiveView(); const sumCol = isIncome ? "summa" : "summadokumenta"; const excludeSumCols = isIncome ? ["data", "summa"] : ["data", "summadokumenta"]; const ruleColumns = new Set(); function collectRuleColumns(cs: IncomeCategory[]) { for (const c of cs) { for (const r of c.rules) { if (allowedCols.includes(r.column) && !excludeSumCols.includes(r.column)) ruleColumns.add(r.column); } if (c.children) collectRuleColumns(c.children); } } collectRuleColumns(cats); const groupCols = ruleColumns.size > 0 ? [...ruleColumns] : ["statya", "dochka", "vnuchka", "otvetstvenniipokontraktu"]; const params: unknown[] = []; const dateConds: string[] = []; if (dateFrom) { params.push(dateFrom); dateConds.push(`data::date >= $${params.length}::date`); } if (dateTo) { params.push(dateTo); dateConds.push(`data::date <= $${params.length}::date`); } const whereClause = dateConds.length > 0 ? `WHERE ${dateConds.join(" AND ")}` : ""; const selectCols = groupCols.join(", "); const aggResult = await query( `SELECT TO_CHAR(data, 'YYYY-MM') as month, ${selectCols}, SUM(${sumCol}) as total FROM ${table} ${whereClause} GROUP BY TO_CHAR(data, 'YYYY-MM'), ${selectCols} ORDER BY month`, params ); const aggRows: Record[] = aggResult.rows.map(r => { const row: Record = { month: r.month, total: Number(r.total) }; for (const col of groupCols) row[col] = r[col] ?? ""; return row; }); function rowMatchesRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; if (val === undefined) return false; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesRules(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (logic === "or") return rules.some(r => rowMatchesRule(row, r)); return rules.every(r => rowMatchesRule(row, r)); } type ExclPath = { id: string; ruleSets: IncomeCategory["rules"][]; logic: "and" | "or" }; const excludedPaths: ExclPath[] = []; type AutoPath = { ruleSets: IncomeCategory["rules"][]; logic: "and" | "or" }; const autoPaths: AutoPath[] = []; function collectPaths(cs: IncomeCategory[], ancestors: IncomeCategory["rules"][], inAutoTree = false) { for (const c of cs) { const isAuto = !!c.autoDetected; const nowInAutoTree = inAutoTree || isAuto; const path = [...ancestors, c.rules]; if (c.excluded) { if (!nowInAutoTree) excludedPaths.push({ id: c.id, ruleSets: path, logic: c.ruleLogic || "and" }); } else if (isAuto) autoPaths.push({ ruleSets: path, logic: c.ruleLogic || "and" }); if (c.children) collectPaths(c.children, path, nowInAutoTree); } } collectPaths(cats, []); function rowMatchesAutoPath(row: Record, ap: AutoPath): boolean { for (let i = 0; i < ap.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ap.ruleSets[i])) return false; } return rowMatchesRules(row, ap.ruleSets[ap.ruleSets.length - 1], ap.logic); } function rowMatchesExclPath(row: Record, ep: ExclPath): boolean { for (let i = 0; i < ep.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ep.ruleSets[i])) return false; } return rowMatchesRules(row, ep.ruleSets[ep.ruleSets.length - 1], ep.logic); } function monthTotalsForCat( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][] ): Map { const allRuleSets = [...ancestorRuleSets, cat.rules]; const catLogic = cat.ruleLogic || "and"; const idx = siblings.findIndex(c => c.id === cat.id); const laterSibs = siblings.slice(idx + 1).filter(c => !c.excluded); const monthMap = new Map(); for (const row of aggRows) { const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; if (laterSibs.some(s => rowMatchesRules(row, s.rules, s.ruleLogic || "and"))) continue; if (!cat.autoDetected && excludedPaths.some(ep => ep.id !== cat.id && rowMatchesExclPath(row, ep))) continue; if (!cat.autoDetected && !!cat.treeId && autoPaths.some(ap => rowMatchesAutoPath(row, ap) && !isRefinementOf(ap.ruleSets, allRuleSets))) continue; if (cat.children && cat.children.length > 0 && cat.children.some(ch => ch.rules.length > 0 && rowMatchesRules(row, ch.rules, ch.ruleLogic || "and"))) continue; const m = row.month as string; monthMap.set(m, (monthMap.get(m) || 0) + row.total); } if (cat.children && cat.children.length > 0) { for (const child of cat.children) { const childMap = monthTotalsForCat(child, cat.children, allRuleSets); for (const [m, v] of childMap) monthMap.set(m, (monthMap.get(m) || 0) + v); } } return monthMap; } const monthsSet = new Set(aggRows.map(r => r.month)); const months = Array.from(monthsSet).sort(); const groupedByTree = new Map(); for (const cat of cats) { const tid = cat.treeId || "__manual__"; if (!groupedByTree.has(tid)) groupedByTree.set(tid, []); groupedByTree.get(tid)!.push(cat); } const config = isIncome ? loadIncomeAutoDetectConfig() : loadAutoDetectConfig(); const configTreeMap = new Map(config.trees.map((t: any) => [t.id, t])); const heatCategories: { id: string; name: string; treeId?: string; treeName?: string; monthly: Record; total: number; excluded?: boolean }[] = []; for (const [treeId, treeCats] of groupedByTree) { if (treeId === "__manual__") continue; const treeMeta = configTreeMap.get(treeId) as any; const treeName = treeMeta?.name || treeId; for (const cat of treeCats) { if (cat.excluded) continue; const monthMap = monthTotalsForCat(cat, treeCats, []); const monthly: Record = {}; let total = 0; for (const m of months) { const v = monthMap.get(m) || 0; monthly[m] = v; total += v; } if (total > 0 || months.some(m => monthly[m] > 0)) { heatCategories.push({ id: cat.id, name: cat.name, treeId, treeName, monthly, total }); } } } heatCategories.sort((a, b) => b.total - a.total); const maxValue = Math.max(0, ...heatCategories.flatMap(c => Object.values(c.monthly))); const totalByMonth: Record = {}; for (const m of months) { totalByMonth[m] = heatCategories.reduce((s, c) => s + (c.monthly[m] || 0), 0); } return { months, categories: heatCategories, maxValue, totalByMonth }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/expenses/tree-timeseries", async (req, res) => { try { await ensureTreesReady(); await autoSyncExpenseCategories(); const cacheKey = stableKey("expense-tree-timeseries", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { dateFrom, dateTo, groupBy = "month", categoryId } = req.query; const validGroupBy = ["day", "month", "quarter", "year"]; const gb = validGroupBy.includes(groupBy as string) ? (groupBy as string) : "month"; const expenseCats = loadExpenseCategories(); const ruleColumns = new Set(); function collectRuleColumns(cats: IncomeCategory[]) { for (const cat of cats) { for (const rule of cat.rules) { if (ALLOWED_EXPENSE_COLUMNS().includes(rule.column) && rule.column !== "data" && rule.column !== "summadokumenta") { ruleColumns.add(rule.column); } } if (cat.children) collectRuleColumns(cat.children); } } collectRuleColumns(expenseCats); const groupCols = ruleColumns.size > 0 ? [...ruleColumns] : ["statya", "dochka", "vnuchka", "otvetstvenniipokontraktu"]; const params: unknown[] = []; const dateConds: string[] = []; if (dateFrom) { params.push(dateFrom); dateConds.push(`data::date >= $${params.length}::date`); } if (dateTo) { params.push(dateTo); dateConds.push(`data::date <= $${params.length}::date`); } const whereClause = dateConds.length > 0 ? `WHERE ${dateConds.join(" AND ")}` : ""; const selectCols = groupCols.join(", "); const aggResult = await query( `SELECT ${selectCols}, DATE_TRUNC('${gb}', data) as period, SUM(summadokumenta) as total, COUNT(*) as cnt FROM ${getExpenseEffectiveView()} ${whereClause} GROUP BY ${selectCols}, DATE_TRUNC('${gb}', data)`, params ); const aggRows: Record[] = aggResult.rows.map(r => { const row: Record = { total: Number(r.total), cnt: Number(r.cnt), period: r.period }; for (const col of groupCols) { row[col] = r[col] ?? ""; } return row; }); function rowMatchesRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { const val = row[rule.column]; if (val === undefined) return false; switch (rule.operator) { case "equals": return val === rule.value; case "contains": return typeof val === "string" && matchTokens(val, rule.value); case "not_equals": return val !== rule.value; case "greater_than": return Number(val) > Number(rule.value); case "less_than": return Number(val) < Number(rule.value); case "equal_number": return Number(val) === Number(rule.value); default: return val === rule.value; } } function rowMatchesRules(row: Record, rules: { column: string; operator: string; value: string }[], logic: "and" | "or" = "and"): boolean { if (logic === "or") return rules.some(rule => rowMatchesRule(row, rule)); return rules.every(rule => rowMatchesRule(row, rule)); } const allFlat = flattenCategories(expenseCats); type AutoPathExpTS = { ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const autoPathsExpTS: AutoPathExpTS[] = []; type ExclPathExpTS = { id: string; ruleSets: IncomeCategory["rules"][]; logic: ("and" | "or") }; const excludedPathsExpTS: ExclPathExpTS[] = []; function collectPathsExpTS(cats: IncomeCategory[], ancestors: IncomeCategory["rules"][], inAutoTree = false) { for (const cat of cats) { const isAuto = !!cat.autoDetected; const nowInAutoTree = inAutoTree || isAuto; const path = [...ancestors, cat.rules]; if (cat.excluded) { if (!nowInAutoTree) { excludedPathsExpTS.push({ id: cat.id, ruleSets: path, logic: cat.ruleLogic || "and" }); } } else if (isAuto) { autoPathsExpTS.push({ ruleSets: path, logic: cat.ruleLogic || "and" }); } if (cat.children) collectPathsExpTS(cat.children, path, nowInAutoTree); } } collectPathsExpTS(expenseCats, []); function rowMatchesAutoPathExpTS(row: Record, ap: AutoPathExpTS): boolean { for (let i = 0; i < ap.ruleSets.length - 1; i++) { if (!rowMatchesRules(row, ap.ruleSets[i])) return false; } return rowMatchesRules(row, ap.ruleSets[ap.ruleSets.length - 1], ap.logic); } const dataPeriods = new Set(); for (const row of aggRows) { if (row.period) dataPeriods.add(new Date(row.period).toISOString()); } const dataPeriodsSorted = [...dataPeriods].sort(); let sortedPeriods = dataPeriodsSorted; if (dataPeriodsSorted.length >= 2) { const filled: string[] = []; const minD = new Date(dataPeriodsSorted[0]); const maxD = new Date(dataPeriodsSorted[dataPeriodsSorted.length - 1]); const cur = new Date(minD); while (cur <= maxD) { filled.push(cur.toISOString()); if (gb === "day") cur.setUTCDate(cur.getUTCDate() + 1); else if (gb === "month") cur.setUTCMonth(cur.getUTCMonth() + 1); else if (gb === "quarter") cur.setUTCMonth(cur.getUTCMonth() + 3); else cur.setUTCFullYear(cur.getUTCFullYear() + 1); } sortedPeriods = filled; } function formatPeriodLabel(iso: string): string { const d = new Date(iso); if (gb === "day") return d.toISOString().slice(0, 10); if (gb === "month") return d.toISOString().slice(0, 7); if (gb === "quarter") { const q = Math.ceil((d.getMonth() + 1) / 3); return `${d.getFullYear()}-Q${q}`; } return `${d.getFullYear()}`; } const periodLabels = sortedPeriods.map(formatPeriodLabel); const periodIndexMap = new Map(sortedPeriods.map((p, i) => [p, i])); function computeCatTimeSeries( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): number[] { const allRuleSets = [...ancestorRuleSets, cat.rules]; const catLogic = cat.ruleLogic || "and"; const idx = siblings.findIndex(c => c.id === cat.id); const laterSibs = siblings.slice(idx + 1).filter(c => !c.excluded); const data = new Array(sortedPeriods.length).fill(0); for (const row of aggRows) { const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs)); if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue; if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue; if (cat.autoDetected && rootManualCats.length > 0 && rootManualCats.some(mc => rowMatchesRules(row, mc.rules, mc.ruleLogic || "and"))) continue; if (!cat.autoDetected && excludedPathsExpTS.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) continue; if (!cat.autoDetected && !!cat.treeId && autoPathsExpTS.some(ap => rowMatchesAutoPathExpTS(row, ap) && !isRefinementOf(ap.ruleSets, allRuleSets))) continue; if (cat.children && cat.children.length > 0 && cat.children.some(child => child.rules.length > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue; const pi = periodIndexMap.get(new Date(row.period).toISOString()); if (pi !== undefined) data[pi] += row.total; } return data; } function subtreeTimeSeriesExp( cat: IncomeCategory, siblings: IncomeCategory[], ancestorRuleSets: IncomeCategory["rules"][], ancestorIds: Set = new Set(), rootManualCats: IncomeCategory[] = [] ): number[] { const own = computeCatTimeSeries(cat, siblings, ancestorRuleSets, ancestorIds, rootManualCats); if (!cat.children || cat.children.length === 0) return own; const allRuleSets = [...ancestorRuleSets, cat.rules]; const newAncestorIds = new Set([...ancestorIds, cat.id]); for (const child of cat.children) { if (child.excluded) continue; const childData = subtreeTimeSeriesExp(child, cat.children, allRuleSets, newAncestorIds, rootManualCats); for (let i = 0; i < own.length; i++) own[i] += childData[i]; } return own; } const config = loadAutoDetectConfig(); const groupedByTree = new Map(); for (const cat of expenseCats) { const tid = cat.treeId || "__manual__"; if (!groupedByTree.has(tid)) groupedByTree.set(tid, []); groupedByTree.get(tid)!.push(cat); } const drillRootManualExp: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") drillRootManualExp.push(...cats.filter(c => !c.autoDetected && !c.excluded)); } if (categoryId && typeof categoryId === "string") { const found = findCategoryWithAncestors(expenseCats, categoryId); if (!found || !found.cat.children || found.cat.children.length === 0) { return { trees: [], drilldown: true }; } const ancestorRuleSets = [...found.ancestors.map(a => a.rules), found.cat.rules]; const drillAncestorIds = new Set([...found.ancestors.map(a => a.id), found.cat.id]); const children = found.cat.children; const isAutoTree = found.cat.autoDetected || found.ancestors.some(a => a.autoDetected); const drillManual = isAutoTree ? drillRootManualExp : []; const series: any[] = children.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeriesExp(cat, children, ancestorRuleSets, drillAncestorIds, drillManual) })).filter(s => s.data.some(v => v !== 0)); const treeId = found.cat.treeId || found.ancestors[0]?.treeId || "__drilldown__"; return { trees: [{ id: treeId, name: found.cat.name, isPrimary: true, periods: periodLabels, series }], drilldown: true }; } const trees: any[] = []; const configTreeIds = new Set(config.trees.map((t: any) => t.id)); const orphanCats: IncomeCategory[] = []; const manualCats: IncomeCategory[] = []; for (const [tid, cats] of groupedByTree) { if (tid === "__manual__") manualCats.push(...cats); else if (!configTreeIds.has(tid)) orphanCats.push(...cats); } type ManualWithAncestorsTrExp = { cat: IncomeCategory; ancestorRuleSets: IncomeCategory["rules"][] }; function collectNestedManualTrExp(cats: IncomeCategory[], ancestorRules: IncomeCategory["rules"][] = []): ManualWithAncestorsTrExp[] { const result: ManualWithAncestorsTrExp[] = []; for (const cat of cats) { if (!cat.autoDetected) result.push({ cat, ancestorRuleSets: ancestorRules }); const nextAncestors = [...ancestorRules, cat.rules]; if (cat.children) result.push(...collectNestedManualTrExp(cat.children, nextAncestors)); } return result; } const rootManualCatsForExclTrExp = manualCats.filter(c => !c.autoDetected && !c.excluded); const rmIdsTrExp = new Set(manualCats.map(c => c.id)); const trendAncestorMapExp = new Map(); for (const [tid, cats] of groupedByTree) { if (tid === "__manual__" || !configTreeIds.has(tid)) continue; for (const cat of cats) { const nested = collectNestedManualTrExp(cat.children || [], [cat.rules]); for (const n of nested) { if (!rmIdsTrExp.has(n.cat.id)) { trendAncestorMapExp.set(n.cat.id, n.ancestorRuleSets); manualCats.push(n.cat); } } } } const otherCats = [...manualCats, ...orphanCats]; if (otherCats.length > 0) { const series = otherCats.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeriesExp(cat, otherCats, trendAncestorMapExp.get(cat.id) || [], new Set()) })).filter(s => s.data.some(v => v !== 0)); trees.push({ id: "__manual__", name: "Ручные категории", isPrimary: true, periods: periodLabels, series }); } for (const treeCfg of config.trees) { const cats = groupedByTree.get(treeCfg.id) || []; const series = cats.filter(c => !c.excluded).map(cat => ({ id: cat.id, name: cat.name, hasChildren: !!(cat.children && cat.children.length > 0), data: subtreeTimeSeriesExp(cat, cats, [], new Set(), rootManualCatsForExclTrExp) })).filter(s => s.data.some(v => v !== 0)); trees.push({ id: treeCfg.id, name: treeCfg.name, isPrimary: treeCfg.isPrimary || false, periods: periodLabels, series }); } return { trees }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/expenses", async (req, res) => { try { const { filters: filtersStr, categoryId, excludeCategories, page = "1", pageSize = "50", sortBy, sortDir } = req.query; const p = Number(page); const ps = Math.min(Number(pageSize), 200); const offset = (p - 1) * ps; const conditions: string[] = []; const params: unknown[] = []; const expenseCats = loadExpenseCategories(); const expenseConfig = loadAutoDetectConfig(); const expensePrimaryTreeIds = new Set(expenseConfig.trees.filter(t => t.isPrimary).map(t => t.id)); let skipExclIds: string | Set | undefined; let rootManualRuleSetsExp: IncomeCategory["rules"][] | undefined; if (categoryId) { const ancestorConditions = buildCategoryWithAncestorsConditions(expenseCats, categoryId as string, params, ALLOWED_EXPENSE_COLUMNS()); conditions.push(...ancestorConditions); const siblingExcl = buildSiblingExclusions(expenseCats, categoryId as string, params, ALLOWED_EXPENSE_COLUMNS()); conditions.push(...siblingExcl); const foundCat = findCategoryById(expenseCats, categoryId as string); if (foundCat) { skipExclIds = new Set(foundCat.path.map(c => c.id)); if (!foundCat.cat.autoDetected) { const ancestorAutoIds = new Set(foundCat.path.filter(c => c.autoDetected).map(c => c.id)); if (ancestorAutoIds.size > 0) { const autoExcl = buildAutoPathExclusionConditions(expenseCats, params, ALLOWED_EXPENSE_COLUMNS(), ancestorAutoIds); conditions.push(...autoExcl); } else { rootManualRuleSetsExp = [foundCat.cat.rules]; if (foundCat.cat.treeId) { const autoExcl = buildAutoPathExclusionConditions(expenseCats, params, ALLOWED_EXPENSE_COLUMNS(), undefined, rootManualRuleSetsExp); conditions.push(...autoExcl); } } } else { const manualExcl = buildManualCatExclusionConditions(expenseCats, params, ALLOWED_EXPENSE_COLUMNS()); conditions.push(...manualExcl); } } else { skipExclIds = categoryId as string; } } else if (excludeCategories === "true") { const excludeConditions = buildExcludeAllCategoriesConditions(expenseCats, params, ALLOWED_EXPENSE_COLUMNS(), expensePrimaryTreeIds); conditions.push(...excludeConditions); conditions.push('"_override_category_id" IS NULL'); } const expenseGlobalExclusions = buildGlobalExclusionConditions(expenseCats, params, skipExclIds, ALLOWED_EXPENSE_COLUMNS(), excludeCategories === "true" ? expensePrimaryTreeIds : undefined, rootManualRuleSetsExp); conditions.push(...expenseGlobalExclusions); const expFilterConditions: string[] = []; if (filtersStr) { try { const filterObj = JSON.parse(String(filtersStr)) as Record; if (filterObj.dateFrom) { params.push(filterObj.dateFrom); expFilterConditions.push(`data::date >= $${params.length}::date`); } if (filterObj.dateTo) { params.push(filterObj.dateTo); expFilterConditions.push(`data::date <= $${params.length}::date`); } for (const [col, val] of Object.entries(filterObj)) { if (col === "dateFrom" || col === "dateTo") continue; if (ALLOWED_EXPENSE_COLUMNS().includes(col) && val) { const colExpr = (col === "summadokumenta" || col === "data") ? `CAST(${col} AS TEXT)` : col; expFilterConditions.push(buildTokenILIKE(colExpr, String(val), params)); } } } catch { /* ignore parse errors */ } } const expWhereParts: string[] = []; if (conditions.length > 0) { expWhereParts.push(...conditions); } expWhereParts.push(...expFilterConditions); const where = expWhereParts.length > 0 ? `WHERE ${expWhereParts.join(" AND ")}` : ""; let orderBy = "ORDER BY data DESC"; if (sortBy && ALL_EXPENSE_COLS().includes(sortBy as string)) { const dir = sortDir === "asc" ? "ASC" : "DESC"; orderBy = `ORDER BY "${sortBy as string}" ${dir} NULLS LAST`; } const selectCols = [...ALL_EXPENSE_COLS().map(c => `"${c}"`), '"_override_category_id"'].join(", "); const [rows, total, sumResult] = await Promise.all([ query(`SELECT ${selectCols} FROM ${getExpenseEffectiveView()} ${where} ${orderBy} LIMIT ${ps} OFFSET ${offset}`, params), query(`SELECT COUNT(*) as cnt FROM ${getExpenseEffectiveView()} ${where}`, params), query(`SELECT COALESCE(SUM(summadokumenta),0) as total_sum FROM ${getExpenseEffectiveView()} ${where}`, params), ]); const totalRows = Number(total.rows[0].cnt); res.json({ rows: rows.rows.map(r => { const row: any = { ...r, data: parseDate(r.data) }; if (r._override_category_id) { row.overrideCategoryId = r._override_category_id; const found = findCategoryById(expenseCats, r._override_category_id); row.overrideCategoryName = found ? found.cat.name : r._override_category_id; } delete row._override_category_id; return row; }), total: totalRows, page: p, pageSize: ps, totalPages: Math.ceil(totalRows / ps), totalSum: Number(sumResult.rows[0].total_sum), }); } catch (err) { handleApiError(res, err); } }); async function buildDebtWhere(req: any) { const { period, klassprosrochki, kontragent, dogovor, otvetstvennii } = req.query; const conditions: string[] = []; const params: unknown[] = []; if (period) { params.push(period); conditions.push(`period = $${params.length}`); } else { const mp = await getMaxPeriod(); params.push(mp); conditions.push(`period = $${params.length}`); } if (klassprosrochki) { const arr = String(klassprosrochki).split(",").filter(Boolean); if (arr.length > 0) { const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); conditions.push(`klassprosrochki IN (${placeholders.join(",")})`); } } if (kontragent) { const arr = String(kontragent).split(",").filter(Boolean); if (arr.length > 0) { const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); conditions.push(`kontragent IN (${placeholders.join(",")})`); } } if (dogovor) { params.push(`%${dogovor}%`); conditions.push(`dogovor ILIKE $${params.length}`); } if (otvetstvennii) { const arr = String(otvetstvennii).split(",").filter(Boolean); if (arr.length > 0) { const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); conditions.push(`otvetstvennii IN (${placeholders.join(",")})`); } } return { where: `WHERE ${conditions.join(" AND ")}`, params }; } const OVERDUE_CLASSES = ["0", "1-30", "31-60", "61-90", "91-365", "> 365"]; function classKey(cls: string | null): string { if (!cls || cls === "0") return "cls0"; if (cls === "1-30") return "cls1_30"; if (cls === "31-60") return "cls31_60"; if (cls === "61-90") return "cls61_90"; if (cls === "91-365") return "cls91_365"; return "cls365plus"; } app.get("/api/debt/analytics", async (req, res) => { try { const cacheKey = stableKey("debt-analytics", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { where, params } = await buildDebtWhere(req); const { page = "1", pageSize = "20" } = req.query; const pg = Number(page); const ps = Math.min(Number(pageSize), 100); const offset = (pg - 1) * ps; const [summaryRes, byKontragentRes, byManagerRes, byClassRes, totalRes, updatedRes, pagedKontragentsRes] = await Promise.all([ query(`SELECT COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue FROM ${getDebtTable()} ${where}`, params), query(`SELECT kontragent, klassprosrochki, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY kontragent, klassprosrochki`, params), query(`SELECT otvetstvennii, klassprosrochki, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY otvetstvennii, klassprosrochki`, params), query(`SELECT CASE WHEN klassprosrochki IS NULL OR klassprosrochki = '0' THEN 'Не просрочено' ELSE klassprosrochki END as cls, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY cls ORDER BY total DESC`, params), query(`SELECT COUNT(DISTINCT kontragent) as cnt FROM ${getDebtTable()} ${where}`, params), query(`SELECT MAX(dataobnovleniyazapisi) as updated_at FROM ${getDebtTable()} ${where}`, params), query(`SELECT kontragent, COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue FROM ${getDebtTable()} ${where} GROUP BY kontragent ORDER BY total_debt DESC LIMIT ${ps} OFFSET ${offset}`, params), ]); const kontragentMap = new Map(); byKontragentRes.rows.forEach((r: any) => { const name = r.kontragent || "Без контрагента"; if (!kontragentMap.has(name)) kontragentMap.set(name, { name, total: 0, cls0: 0, cls1_30: 0, cls31_60: 0, cls61_90: 0, cls91_365: 0, cls365plus: 0 }); const entry = kontragentMap.get(name)!; const val = Number(r.total); entry.total += val; entry[classKey(r.klassprosrochki)] += val; }); const byKontragent = Array.from(kontragentMap.values()).sort((a, b) => b.total - a.total).slice(0, 10); const managerMap = new Map(); byManagerRes.rows.forEach((r: any) => { const name = r.otvetstvennii && r.otvetstvennii !== "0" ? r.otvetstvennii : "Не назначен"; if (!managerMap.has(name)) managerMap.set(name, { name, total: 0, cls0: 0, cls1_30: 0, cls31_60: 0, cls61_90: 0, cls91_365: 0, cls365plus: 0 }); const entry = managerMap.get(name)!; const val = Number(r.total); entry.total += val; entry[classKey(r.klassprosrochki)] += val; }); const byManager = Array.from(managerMap.values()).sort((a, b) => b.total - a.total).slice(0, 10); const byOverdueClass = byClassRes.rows.map((r: any) => ({ cls: r.cls, total: Number(r.total) })); const pagedKontragentNames = pagedKontragentsRes.rows.map((r: any) => r.kontragent); let pagedGroups: any[] = []; const totalKontragents = Number(totalRes.rows[0].cnt); if (pagedKontragentNames.length > 0) { const detailParams = [...params]; const namePlaceholders = pagedKontragentNames.map((n: string) => { detailParams.push(n); return `$${detailParams.length}`; }); const detailRes = await query(` SELECT kontragent, dogovor, COALESCE(summadokumenta,0) as summadokumenta, dokumentraschetov, COALESCE(summaoplacheno,0) as summaoplacheno, COALESCE(summadolga,0) as summadolga, COALESCE(summaprosrocheno,0) as summaprosrocheno, COALESCE(otsrochkapodogovoru,0) as otsrochkapodogovoru, COALESCE(dneiprosrocheno,0) as dneiprosrocheno FROM ${getDebtTable()} ${where} AND kontragent IN (${namePlaceholders.join(",")}) ORDER BY kontragent, summadolga DESC `, detailParams); const groupMap = new Map(); pagedKontragentsRes.rows.forEach((r: any) => { groupMap.set(r.kontragent, { kontragent: r.kontragent, totalDebt: Number(r.total_debt), totalOverdue: Number(r.total_overdue), rows: [] }); }); detailRes.rows.forEach((r: any) => { const g = groupMap.get(r.kontragent); if (g) { g.rows.push({ dogovor: r.dogovor || "", summadokumenta: Number(r.summadokumenta), dokumentraschetov: r.dokumentraschetov || "", summaoplacheno: Number(r.summaoplacheno), summadolga: Number(r.summadolga), summaprosrocheno: Number(r.summaprosrocheno), otsrochkapodogovoru: Number(r.otsrochkapodogovoru), dneiprosrocheno: Number(r.dneiprosrocheno), }); } }); pagedGroups = pagedKontragentsRes.rows.map((r: any) => groupMap.get(r.kontragent)!); } return { summary: { updatedAt: updatedRes.rows[0]?.updated_at ? parseDate(updatedRes.rows[0].updated_at) : null, totalDebt: Number(summaryRes.rows[0].total_debt), totalOverdue: Number(summaryRes.rows[0].total_overdue), }, byKontragent, byManager, byOverdueClass, table: { rows: pagedGroups, total: totalKontragents, page: pg, pageSize: ps, totalPages: Math.ceil(totalKontragents / ps), }, }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/debt/dynamics", async (req, res) => { try { const cacheKey = stableKey("debt-dynamics", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { dateFrom, dateTo, kontragent } = req.query; const conditions: string[] = []; const params: unknown[] = []; if (dateFrom) { params.push(dateFrom); conditions.push(`period >= $${params.length}`); } if (dateTo) { params.push(dateTo); conditions.push(`period <= $${params.length}`); } if (kontragent) { params.push(kontragent); conditions.push(`kontragent = $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const dbResult = await query(` SELECT period, COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue FROM ${getDebtTable()} ${where} GROUP BY period ORDER BY period `, params); let rows = dbResult.rows.map((r: any) => ({ period: parseDate(r.period), totalDebt: Number(r.total_debt), totalOverdue: Number(r.total_overdue), })); if (rows.length > 300) { const step = Math.ceil(rows.length / 300); rows = rows.filter((_: any, i: number) => i % step === 0 || i === rows.length - 1); } return rows; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/debt/dynamics/kontragenty", async (req, res) => { try { const cacheKey = stableKey("debt-dyn-kontr", req.query as Record); const result = await cachedQuery(cacheKey, TTL_SHORT, async () => { const { period, dateTo } = req.query; const params: unknown[] = []; let periodClause: string; if (period) { params.push(period); periodClause = `period = $${params.length}`; } else if (dateTo) { params.push(dateTo); periodClause = `period = (SELECT MAX(period) FROM ${getDebtTable()} WHERE period <= $${params.length})`; } else { const mp = await getMaxPeriod(); params.push(mp); periodClause = `period = $${params.length}`; } const where = `WHERE ${periodClause}`; const dbResult = await query(` SELECT kontragent, COALESCE(SUM(summadokumenta),0) as summadokumenta, COALESCE(SUM(summaoplacheno),0) as summaoplacheno, COALESCE(SUM(summadolga),0) as summadolga, COALESCE(SUM(summaprosrocheno),0) as summaprosrocheno, MAX(dneiprosrocheno) as dneiprosrocheno FROM ${getDebtTable()} ${where} GROUP BY kontragent ORDER BY summadolga DESC `, params); return dbResult.rows.map((r: any) => ({ kontragent: r.kontragent, summadokumenta: Number(r.summadokumenta), summaoplacheno: Number(r.summaoplacheno), summadolga: Number(r.summadolga), summaprosrocheno: Number(r.summaprosrocheno), dneiprosrocheno: Number(r.dneiprosrocheno), })); }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.get("/api/debt/filters", async (_req, res) => { try { const result = await cachedQuery("debt-filters", TTL_LONG, async () => { const maxPeriod = await getMaxPeriod(); const [periods, klassprosrochki, organizaciya, otvetstvennii, kontragenty] = await Promise.all([ query(`SELECT DISTINCT period FROM ${getDebtTable()} ORDER BY period DESC LIMIT 30`), query(`SELECT DISTINCT klassprosrochki FROM ${getDebtTable()} WHERE klassprosrochki IS NOT NULL ORDER BY klassprosrochki`), query(`SELECT DISTINCT organizaciya FROM ${getDebtTable()} WHERE organizaciya IS NOT NULL ORDER BY organizaciya`), query(`SELECT DISTINCT otvetstvennii FROM ${getDebtTable()} WHERE period = $1 AND otvetstvennii IS NOT NULL AND otvetstvennii != '0' ORDER BY otvetstvennii`, [maxPeriod]), query(`SELECT DISTINCT kontragent FROM ${getDebtTable()} WHERE period = $1 AND kontragent IS NOT NULL ORDER BY kontragent`, [maxPeriod]), ]); return { periods: periods.rows.map(r => parseDate(r.period)), klassprosrochki: klassprosrochki.rows.map(r => r.klassprosrochki), organizaciya: organizaciya.rows.map(r => r.organizaciya), otvetstvennii: otvetstvennii.rows.map(r => r.otvetstvennii), kontragenty: kontragenty.rows.map(r => r.kontragent), }; }); res.json(result); } catch (err) { handleApiError(res, err); } }); app.post("/api/calculations/profit-tax", async (req, res) => { try { await ensureTreesReady(); const { dateFrom, dateTo, taxRate: taxRateParam } = req.query; const { incomeCategories, expenseCategories, chartCategories } = req.body || {}; const parseRate = (r: string): number => { const m = r.match(/(\d+)/); return m ? Number(m[1]) / 100 : 0; }; const isNoVatRate = (r: string): boolean => parseRate(r) === 0; const tableNames = await getPrimaryTreeTables(); const [incExistArr, expExistArr] = await Promise.all([ Promise.all(tableNames.income.map(t => treeTableExists(t))), Promise.all(tableNames.expense.map(t => treeTableExists(t))), ]); const incTables = tableNames.income.filter((_, i) => incExistArr[i]); const expTables = tableNames.expense.filter((_, i) => expExistArr[i]); if (incTables.length === 0 || expTables.length === 0) { scheduleTreeRebuild(); return res.status(202).json({ error: "Таблицы деревьев строятся, повторите запрос через несколько секунд", rebuilding: true }); } function buildUnionParams(tables: string[], amountCol: string, categoryIds?: string[]): { byRateSQL: string; monthlySQL: string; params: unknown[] } { const allParams: unknown[] = []; const selects = tables.map(t => { const conds: string[] = ["_is_excluded = FALSE"]; if (categoryIds && categoryIds.length > 0) { const ph = categoryIds.map(id => { allParams.push(id); return `$${allParams.length}`; }); conds.push(`_category_id IN (${ph.join(",")})`); } if (dateFrom) { allParams.push(dateFrom); conds.push(`data >= $${allParams.length}`); } if (dateTo) { allParams.push(dateTo); conds.push(`data <= $${allParams.length}`); } return `SELECT ${amountCol} as amount, COALESCE(stavkands,'Без НДС') as rate, data FROM "${t}" WHERE ${conds.join(" AND ")}`; }); const unionSQL = selects.join(" UNION ALL "); const byRateSQL = `SELECT rate, COALESCE(SUM(amount),0) as total FROM (${unionSQL}) _u GROUP BY rate`; const monthlySQL = `SELECT TO_CHAR(data,'YYYY-MM') as period, rate, COALESCE(SUM(amount),0) as total FROM (${unionSQL}) _u GROUP BY TO_CHAR(data,'YYYY-MM'), rate ORDER BY period`; return { byRateSQL, monthlySQL, params: allParams }; } const incCatFilter = incomeCategories && Array.isArray(incomeCategories) && incomeCategories.length > 0 ? incomeCategories as string[] : undefined; const expCatFilter = expenseCategories && Array.isArray(expenseCategories) && expenseCategories.length > 0 ? expenseCategories as string[] : undefined; const incUnion = buildUnionParams(incTables, "summa", incCatFilter); const expUnion = buildUnionParams(expTables, "summadokumenta", expCatFilter); const [incByRate, expByRate, incMonthly, expMonthly] = await Promise.all([ query(incUnion.byRateSQL, incUnion.params), query(expUnion.byRateSQL, expUnion.params), query(incUnion.monthlySQL, incUnion.params), query(expUnion.monthlySQL, expUnion.params), ]); let incomeWithVat = 0, incomeVatAmount = 0, incomeNoVat = 0, totalIncome = 0; for (const r of incByRate.rows as any[]) { const total = Number(r.total); totalIncome += total; if (isNoVatRate(r.rate)) { incomeNoVat += total; } else { const rate = parseRate(r.rate); const vat = total * rate / (1 + rate); incomeWithVat += total; incomeVatAmount += vat; } } let expenseWithVat = 0, expenseVatAmount = 0, expenseNoVat = 0, totalExpenses = 0; for (const r of expByRate.rows as any[]) { const total = Number(r.total); totalExpenses += total; if (isNoVatRate(r.rate)) { expenseNoVat += total; } else { const rate = parseRate(r.rate); const vat = total * rate / (1 + rate); expenseWithVat += total; expenseVatAmount += vat; } } const incomeWithVatBase = incomeWithVat - incomeVatAmount; const expenseWithVatBase = expenseWithVat - expenseVatAmount; const profit = (incomeWithVatBase - expenseWithVatBase) + (incomeNoVat - expenseNoVat); const taxRate = taxRateParam ? Number(taxRateParam) / 100 : 0.20; const tax = profit > 0 ? profit * taxRate : 0; const periodsSet = new Set(); [...(incMonthly.rows as any[]), ...(expMonthly.rows as any[])].forEach((r: any) => periodsSet.add(r.period)); const periods = [...periodsSet].sort(); const monthly = periods.map(p => { let mIncVat = 0, mIncVatAmt = 0, mIncNoVat = 0, mInc = 0; for (const r of incMonthly.rows as any[]) { if (r.period !== p) continue; const total = Number(r.total); mInc += total; if (isNoVatRate(r.rate)) { mIncNoVat += total; } else { const rate = parseRate(r.rate); mIncVat += total; mIncVatAmt += total * rate / (1 + rate); } } let mExpVat = 0, mExpVatAmt = 0, mExpNoVat = 0, mExp = 0; for (const r of expMonthly.rows as any[]) { if (r.period !== p) continue; const total = Number(r.total); mExp += total; if (isNoVatRate(r.rate)) { mExpNoVat += total; } else { const rate = parseRate(r.rate); mExpVat += total; mExpVatAmt += total * rate / (1 + rate); } } const prof = ((mIncVat - mIncVatAmt) - (mExpVat - mExpVatAmt)) + (mIncNoVat - mExpNoVat); return { period: p, income: Math.round(mInc * 100) / 100, expenses: Math.round(mExp * 100) / 100, profit: Math.round(prof * 100) / 100, tax: Math.round((prof > 0 ? prof * taxRate : 0) * 100) / 100 }; }); // Excluded categories from materialized tables function buildExcludedUnion(tables: string[], amountCol: string): { sql: string; params: unknown[] } { const allParams: unknown[] = []; const selects = tables.map(t => { const conds: string[] = ["_is_excluded = TRUE"]; if (dateFrom) { allParams.push(dateFrom); conds.push(`data >= $${allParams.length}`); } if (dateTo) { allParams.push(dateTo); conds.push(`data <= $${allParams.length}`); } return `SELECT _category_name, ${amountCol} as amount FROM "${t}" WHERE ${conds.join(" AND ")}`; }); return { sql: `SELECT _category_name, COALESCE(SUM(amount),0) as total FROM (${selects.join(" UNION ALL ")}) _e GROUP BY _category_name`, params: allParams }; } const incExcl = buildExcludedUnion(incTables, "summa"); const expExcl = buildExcludedUnion(expTables, "summadokumenta"); const [incExclResult, expExclResult] = await Promise.all([ query(incExcl.sql, incExcl.params), query(expExcl.sql, expExcl.params), ]); const excludedIncomeCategories = (incExclResult.rows as any[]).filter(r => Number(r.total) > 0).map(r => ({ name: r._category_name, total: Math.round(Number(r.total) * 100) / 100 })); const excludedExpenseCategories = (expExclResult.rows as any[]).filter(r => Number(r.total) > 0).map(r => ({ name: r._category_name, total: Math.round(Number(r.total) * 100) / 100 })); let categoryMonthly: { id: string; name: string; type: string; data: { period: string; amount: number }[] }[] = []; if (chartCategories && Array.isArray(chartCategories) && chartCategories.length > 0) { const chartCatPromises = (chartCategories as { id: string; type: string; name?: string }[]).map(async (cc) => { const tables = cc.type === "income" ? incTables : expTables; const amountCol = cc.type === "income" ? "summa" : "summadokumenta"; const catParams: unknown[] = []; const catSelects = tables.map(t => { const conds: string[] = ["_is_excluded = FALSE"]; catParams.push(cc.id); conds.push(`_category_id = $${catParams.length}`); if (dateFrom) { catParams.push(dateFrom); conds.push(`data >= $${catParams.length}`); } if (dateTo) { catParams.push(dateTo); conds.push(`data <= $${catParams.length}`); } return `SELECT ${amountCol} as amount, data FROM "${t}" WHERE ${conds.join(" AND ")}`; }); const catSQL = `SELECT TO_CHAR(data,'YYYY-MM') as period, COALESCE(SUM(amount),0) as total FROM (${catSelects.join(" UNION ALL ")}) _cc GROUP BY TO_CHAR(data,'YYYY-MM') ORDER BY period`; const catResult = await query(catSQL, catParams); return { id: cc.id, name: cc.name || cc.id, type: cc.type, data: (catResult.rows as any[]).map((r: any) => ({ period: r.period, amount: Math.round(Number(r.total) * 100) / 100 })), }; }); categoryMonthly = await Promise.all(chartCatPromises); } const r2 = (v: number) => Math.round(v * 100) / 100; res.json({ totalIncome: r2(totalIncome), totalExpenses: r2(totalExpenses), incomeWithVat: r2(incomeWithVat), incomeVatAmount: r2(incomeVatAmount), incomeWithVatBase: r2(incomeWithVatBase), incomeNoVat: r2(incomeNoVat), expenseWithVat: r2(expenseWithVat), expenseVatAmount: r2(expenseVatAmount), expenseWithVatBase: r2(expenseWithVatBase), expenseNoVat: r2(expenseNoVat), profit: r2(profit), taxRate, tax: r2(tax), monthly, excludedIncomeCategories, excludedExpenseCategories, categoryMonthly, }); } catch (err) { handleApiError(res, err); } }); app.post("/api/planning/forecast", async (req, res) => { try { const minMonths = Math.max(2, Math.min(12, Number(req.body?.minConsecutiveMonths) || 2)); const tolerancePct = Math.max(0, Math.min(100, Number(req.body?.tolerancePercent) || 20)); const [incResult, expResult] = await Promise.all([ cachedQuery("planning-inc-monthly", TTL_SHORT, () => query(`SELECT zakazchik, dogovorzakazchika, TO_CHAR(data, 'YYYY-MM') as period, SUM(summa) as total, COUNT(*) as cnt FROM ${getIncomeEffectiveView()} WHERE zakazchik IS NOT NULL AND zakazchik != '0' AND dogovorzakazchika IS NOT NULL AND dogovorzakazchika != '0' GROUP BY zakazchik, dogovorzakazchika, TO_CHAR(data, 'YYYY-MM') ORDER BY period`) ), cachedQuery("planning-exp-monthly", TTL_SHORT, () => query(`SELECT zakazchik, dogovorzakazchika, TO_CHAR(data, 'YYYY-MM') as period, SUM(summadokumenta) as total, COUNT(*) as cnt FROM ${getExpenseEffectiveView()} WHERE zakazchik IS NOT NULL AND zakazchik != '0' AND dogovorzakazchika IS NOT NULL AND dogovorzakazchika != '0' GROUP BY zakazchik, dogovorzakazchika, TO_CHAR(data, 'YYYY-MM') ORDER BY period`) ), ]); const allPeriods = new Set(); for (const r of incResult.rows as any[]) allPeriods.add(r.period); for (const r of expResult.rows as any[]) allPeriods.add(r.period); const sortedPeriods = [...allPeriods].sort(); if (sortedPeriods.length === 0) { return res.json({ historical: [], forecast: [], recurringIncome: [], recurringExpenses: [], lastPeriod: null }); } const lastPeriod = sortedPeriods[sortedPeriods.length - 1]; function detectRecurring(rows: any[], datasetPeriods: Set) { const byKey = new Map>(); for (const r of rows) { const key = `${r.zakazchik}|||${r.dogovorzakazchika}`; if (!byKey.has(key)) byKey.set(key, new Map()); const existing = byKey.get(key)!.get(r.period) || 0; byKey.get(key)!.set(r.period, existing + Number(r.total)); } const dsLatest = [...datasetPeriods].sort(); const dsLastPeriod = dsLatest.length > 0 ? dsLatest[dsLatest.length - 1] : lastPeriod; const dsSecondLast = dsLatest.length > 1 ? dsLatest[dsLatest.length - 2] : null; const recurring: { name: string; contract: string; avgAmount: number; months: number; monthlyAmounts: Map }[] = []; for (const [key, periodMap] of byKey) { const periods = [...periodMap.keys()].sort(); if (periods.length < minMonths) continue; const runs: string[][] = []; let currentRun: string[] = [periods[0]]; for (let i = 1; i < periods.length; i++) { const prevDate = new Date(periods[i - 1] + "-01"); const expectedNext = new Date(prevDate); expectedNext.setMonth(expectedNext.getMonth() + 1); const expectedStr = expectedNext.toISOString().substring(0, 7); if (periods[i] === expectedStr) { currentRun.push(periods[i]); } else { runs.push(currentRun); currentRun = [periods[i]]; } } runs.push(currentRun); let selectedRun: string[] | null = null; for (let i = runs.length - 1; i >= 0; i--) { const run = runs[i]; if (run.length < minMonths) continue; const runEnd = run[run.length - 1]; if (runEnd === dsLastPeriod || runEnd === dsSecondLast) { selectedRun = run; break; } } if (!selectedRun) continue; const amounts = selectedRun.map(p => periodMap.get(p) || 0); const avg = amounts.reduce((a, b) => a + b, 0) / amounts.length; if (avg <= 0) continue; const withinTolerance = amounts.every(a => Math.abs(a - avg) / avg * 100 <= tolerancePct); if (!withinTolerance) { const median = [...amounts].sort((a, b) => a - b)[Math.floor(amounts.length / 2)]; const medianCheck = amounts.every(a => Math.abs(a - median) / median * 100 <= tolerancePct); if (!medianCheck) continue; } const [zakazchik, contract] = key.split("|||"); const monthlyAmounts = new Map(); for (const p of selectedRun) monthlyAmounts.set(p, periodMap.get(p) || 0); recurring.push({ name: zakazchik, contract, avgAmount: Math.round(avg * 100) / 100, months: selectedRun.length, monthlyAmounts }); } return recurring; } const incPeriods = new Set((incResult.rows as any[]).map((r: any) => r.period)); const expPeriods = new Set((expResult.rows as any[]).map((r: any) => r.period)); const recurringIncome = detectRecurring(incResult.rows as any[], incPeriods); const recurringExpenses = detectRecurring(expResult.rows as any[], expPeriods); const historical = new Map(); for (const p of sortedPeriods) { historical.set(p, { income: 0, expenses: 0 }); } for (const r of incResult.rows as any[]) { const h = historical.get(r.period); if (h) h.income += Number(r.total); } for (const r of expResult.rows as any[]) { const h = historical.get(r.period); if (h) h.expenses += Number(r.total); } const historicalData = sortedPeriods.map(p => { const h = historical.get(p)!; return { period: p, income: Math.round(h.income * 100) / 100, expenses: Math.round(h.expenses * 100) / 100, net: Math.round((h.income - h.expenses) * 100) / 100, type: "historical" as const, }; }); const forecastMonths: string[] = []; const lastDate = new Date(lastPeriod + "-01"); for (let i = 1; i <= 12; i++) { const d = new Date(lastDate); d.setMonth(d.getMonth() + i); forecastMonths.push(d.toISOString().substring(0, 7)); } const forecastData = forecastMonths.map(p => { let forecastIncome = 0; let forecastExpenses = 0; for (const ri of recurringIncome) forecastIncome += ri.avgAmount; for (const re of recurringExpenses) forecastExpenses += re.avgAmount; return { period: p, income: Math.round(forecastIncome * 100) / 100, expenses: Math.round(forecastExpenses * 100) / 100, net: Math.round((forecastIncome - forecastExpenses) * 100) / 100, type: "forecast" as const, }; }); res.json({ historical: historicalData, forecast: forecastData, recurringIncome: recurringIncome.map(r => ({ name: r.name, contract: r.contract, avgAmount: r.avgAmount, months: r.months })), recurringExpenses: recurringExpenses.map(r => ({ name: r.name, contract: r.contract, avgAmount: r.avgAmount, months: r.months })), lastPeriod, }); } catch (err) { handleApiError(res, err); } }); // --- AI Planning Analysis --- const PLANNING_ITEMS_PATH = path.join(process.cwd(), "data", "planning-items.json"); interface PlanItem { id: string; type: "income" | "expense"; name: string; contract: string; monthlyAmount: number; confidence: string; recurring?: boolean; reasoning: string; enabled: boolean; source: "ai" | "manual"; } function loadPlanItems(): PlanItem[] { try { if (fs.existsSync(PLANNING_ITEMS_PATH)) { return JSON.parse(fs.readFileSync(PLANNING_ITEMS_PATH, "utf-8")); } } catch {} return []; } function savePlanItems(items: PlanItem[]): void { fs.writeFileSync(PLANNING_ITEMS_PATH, JSON.stringify(items, null, 2)); } app.get("/api/planning/saved", (_req, res) => { res.json(loadPlanItems()); }); app.post("/api/planning/saved", (req, res) => { try { const items = req.body; if (!Array.isArray(items)) return res.status(400).json({ error: "Ожидается массив" }); if (items.length > 200) return res.status(400).json({ error: "Слишком много элементов (макс. 200)" }); const validated: PlanItem[] = items.map((it: any, idx: number) => ({ id: String(it.id || `item-${idx}`), type: it.type === "income" ? "income" : "expense", name: String(it.name || "").slice(0, 500), contract: String(it.contract || "").slice(0, 500), monthlyAmount: typeof it.monthlyAmount === "number" ? it.monthlyAmount : 0, confidence: ["high", "medium", "low"].includes(it.confidence) ? it.confidence : "medium", recurring: typeof it.recurring === "boolean" ? it.recurring : false, reasoning: String(it.reasoning || "").slice(0, 1000), enabled: Boolean(it.enabled), source: it.source === "manual" ? "manual" : "ai", })); savePlanItems(validated); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка сохранения" }); } }); app.get("/api/planning/categories", async (_req, res) => { try { const incomeCats = loadCategories(); const expenseCats = loadExpenseCategories(); const mapCats = (cats: IncomeCategory[]): { id: string; name: string }[] => cats.filter(c => !c.excluded).map(c => ({ id: c.id, name: c.name })); res.json({ income: mapCats(incomeCats), expenses: mapCats(expenseCats) }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка загрузки категорий" }); } }); app.post("/api/planning/ai-analyze", async (req, res) => { const startTime = Date.now(); try { const cfg = loadAiPlanningConfig(); if (!cfg.apiKey) return res.status(400).json({ error: "AI Аналитик не настроен. Укажите API ключ в настройках AI (вкладка Аналитик)." }); const months = Math.max(3, Math.min(24, Number(req.body?.months) || 6)); const selectedExpenseIds: string[] = Array.isArray(req.body?.expenseCategories) ? req.body.expenseCategories : []; const cutoffDate = new Date(); cutoffDate.setMonth(cutoffDate.getMonth() - months); const cutoff = cutoffDate.toISOString().substring(0, 10); const incomeCats = loadCategories(); const expenseCats = loadExpenseCategories(); const topIncomeIds = incomeCats.filter(c => !c.excluded).map(c => c.id); const effectiveExpenseIds = selectedExpenseIds.length > 0 ? selectedExpenseIds : expenseCats.filter(c => !c.excluded).map(c => c.id); const useSelectedExpenseFilter = selectedExpenseIds.length > 0; async function queryCategoryData( table: string, sumCol: string, cats: IncomeCategory[], selectedIds: string[], allowedCols: string[], cutoffStr: string, ): Promise<{ byCat: Map; names: Map }> { const byCat = new Map(); const names = new Map(); const allFlat = flattenCategories(cats); const BATCH_SIZE = 10; for (let i = 0; i < selectedIds.length; i += BATCH_SIZE) { const batch = selectedIds.slice(i, i + BATCH_SIZE); await Promise.all(batch.map(async (catId) => { const cat = allFlat.find(c => c.id === catId); if (!cat) return; names.set(catId, cat.name); const params: unknown[] = []; const conds = buildCategoryWithAncestorsConditions(cats, catId, params, allowedCols); params.push(cutoffStr); const dateIdx = params.length; const whereClause = conds.length > 0 ? `WHERE data >= $${dateIdx}::date AND ${conds.join(" AND ")}` : `WHERE data >= $${dateIdx}::date`; const sql = `SELECT TO_CHAR(data, 'YYYY-MM') as period, SUM(${sumCol}::numeric) as total FROM ${table} ${whereClause} GROUP BY TO_CHAR(data, 'YYYY-MM') ORDER BY period`; const result = await query(sql, params); const entry = { periods: [] as string[], amounts: [] as number[], total: 0 }; for (const r of result.rows as any[]) { entry.periods.push(r.period); const amt = Number(r.total); entry.amounts.push(amt); entry.total += amt; } if (entry.total > 0) byCat.set(catId, entry); })); } return { byCat, names }; } const baseQueries = [ query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, SUM(summa::numeric) as total FROM ${getIncomeEffectiveView()} WHERE data >= '${cutoff}'::date GROUP BY TO_CHAR(data, 'YYYY-MM') ORDER BY period`), query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, SUM(summadokumenta::numeric) as total FROM ${getExpenseEffectiveView()} WHERE data >= '${cutoff}'::date GROUP BY TO_CHAR(data, 'YYYY-MM') ORDER BY period`), query(`SELECT COALESCE(SUM(summadolga::numeric),0) as total_debt, COALESCE(SUM(summaprosrocheno::numeric),0) as total_overdue, COUNT(DISTINCT kontragent) as cnt FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()})`), query(`SELECT kontragent, dogovor, COALESCE(SUM(summadokumenta::numeric),0) as doc_total, COALESCE(SUM(summaoplacheno::numeric),0) as paid_total, COALESCE(SUM(summadolga::numeric),0) as debt_total, COALESCE(SUM(summaprosrocheno::numeric),0) as overdue_total, MAX(COALESCE(dneiprosrocheno::numeric,0)) as max_days FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()}) GROUP BY kontragent, dogovor ORDER BY debt_total DESC LIMIT 20`), query(`SELECT CASE WHEN klassprosrochki IS NULL OR klassprosrochki = '0' THEN 'Не просрочено' ELSE klassprosrochki END as cls, COALESCE(SUM(summadolga::numeric),0) as total FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()}) GROUP BY cls ORDER BY total DESC`), ]; const baseResults = await Promise.all(baseQueries); const [incTotals, expTotals, debtSummary, debtTop, debtByClass] = baseResults; console.log(`[AI Planning] Querying categories: income=${topIncomeIds.length}, expense=${effectiveExpenseIds.length}${useSelectedExpenseFilter ? " (выбраны)" : " (все)"}`); async function fetchSalarySummary(monthsCount: number): Promise { try { const status = getAuthStatus(); if (!status.loggedIn) return ""; const areas = loadAreas(); if (areas.length === 0) return ""; let salaryText = `\n## Зарплаты по участкам (Google Sheets)\n`; let hasData = false; for (const area of areas) { try { const sheetNames = await getSheetNames(area.spreadsheetId); const monthSheets = sheetNames .filter(name => MONTH_PATTERN.test(name)) .sort((a, b) => { const [, am, ay] = a.match(MONTH_PATTERN)!; const [, bm, by] = b.match(MONTH_PATTERN)!; return (Number(ay) * 12 + Number(am)) - (Number(by) * 12 + Number(bm)); }) .slice(-monthsCount); if (monthSheets.length === 0) continue; const fioIdx = colLetterToIndex(area.fioCol || "B"); const bazaIdx = colLetterToIndex(area.bazaCol || "F"); const kVydacheIdx = colLetterToIndex(area.kVydacheCol || "J"); const lastCol = maxColLetter(area.fioCol || "B", area.bazaCol || "F", area.kVydacheCol || "J"); const monthlyTotals: { month: string; totalBaza: number; totalKVydache: number; empCount: number }[] = []; for (const sheetName of monthSheets) { const rows = await getSheetData(area.spreadsheetId, `'${sheetName}'!A4:${lastCol}500`); let totalBaza = 0, totalKVydache = 0, empCount = 0; for (const row of rows) { if (!row || !row[fioIdx] || !String(row[fioIdx]).trim()) continue; const fio = String(row[fioIdx] || "").replace(/[0-9]/g, "").replace(/[^\p{L}\s\-().]/gu, "").replace(/\s+/g, " ").trim(); if (!fio) continue; totalBaza += Number(String(row[bazaIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; totalKVydache += Number(String(row[kVydacheIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; empCount++; } if (empCount > 0) monthlyTotals.push({ month: sheetName, totalBaza, totalKVydache, empCount }); } if (monthlyTotals.length > 0) { hasData = true; salaryText += `- ${area.name} | Месяцев: ${monthlyTotals.length} | Периоды: ${monthlyTotals.map(m => m.month).join(",")} | К выдаче: ${monthlyTotals.map(m => m.totalKVydache.toFixed(0)).join(",")} | Сотрудников: ${monthlyTotals.map(m => m.empCount).join(",")}\n`; } } catch (err: any) { console.error(`[AI Planning] Salary area ${area.name} error:`, err?.message); } } return hasData ? salaryText : ""; } catch (err: any) { console.error("[AI Planning] Salary fetch error:", err?.message); return ""; } } const [incCatData, expCatData, salarySection] = await Promise.all([ queryCategoryData(getIncomeEffectiveView(), "summa", incomeCats, topIncomeIds, ALLOWED_INCOME_COLUMNS(), cutoff), queryCategoryData(getExpenseEffectiveView(), "summadokumenta", expenseCats, effectiveExpenseIds, ALLOWED_EXPENSE_COLUMNS(), cutoff), fetchSalarySummary(months), ]); let dataSummary = `Период анализа: последние ${months} месяцев (с ${cutoff})\n\n`; dataSummary += `## Общие итоги по месяцам\n`; dataSummary += `Доходы: ${(incTotals.rows as any[]).map((r: any) => `${r.period}: ${Number(r.total).toFixed(0)}`).join(", ")}\n`; dataSummary += `Расходы: ${(expTotals.rows as any[]).map((r: any) => `${r.period}: ${Number(r.total).toFixed(0)}`).join(", ")}\n\n`; { const sortedInc = [...incCatData.byCat.entries()].sort((a, b) => b[1].total - a[1].total); dataSummary += `## Доходы по категориям (${sortedInc.length} из ${topIncomeIds.length}, excluded исключены)\n`; for (const [catId, data] of sortedInc) { dataSummary += `- ${incCatData.names.get(catId) || catId} | Месяцев: ${data.periods.length} | Итого: ${data.total.toFixed(0)} | Периоды: ${data.periods.join(",")} | Суммы: ${data.amounts.map(a => a.toFixed(0)).join(",")}\n`; } } { const sortedExp = [...expCatData.byCat.entries()].sort((a, b) => b[1].total - a[1].total); dataSummary += `\n## Расходы по категориям (${sortedExp.length} из ${effectiveExpenseIds.length}${useSelectedExpenseFilter ? ", выбраны вручную" : ", excluded исключены"})\n`; for (const [catId, data] of sortedExp) { dataSummary += `- ${expCatData.names.get(catId) || catId} | Месяцев: ${data.periods.length} | Итого: ${data.total.toFixed(0)} | Периоды: ${data.periods.join(",")} | Суммы: ${data.amounts.map(a => a.toFixed(0)).join(",")}\n`; } } const ds = debtSummary.rows[0] as any; dataSummary += `\n## Дебиторская задолженность\n`; dataSummary += `Общий долг: ${Number(ds?.total_debt || 0).toFixed(0)} руб | Просрочено: ${Number(ds?.total_overdue || 0).toFixed(0)} руб | Контрагентов: ${ds?.cnt || 0}\n\n`; dataSummary += `Распределение по классам просрочки:\n`; for (const r of debtByClass.rows as any[]) { dataSummary += `- ${r.cls}: ${Number(r.total).toFixed(0)} руб\n`; } dataSummary += `\nТоп-20 должников:\n`; for (const r of debtTop.rows as any[]) { dataSummary += `- ${r.kontragent} | ${r.dogovor || "б/д"} | Выставлено: ${Number(r.doc_total).toFixed(0)} | Оплачено: ${Number(r.paid_total).toFixed(0)} | Долг: ${Number(r.debt_total).toFixed(0)} | Просрочка: ${Number(r.overdue_total).toFixed(0)} | Дней: ${Number(r.max_days).toFixed(0)}\n`; } if (salarySection) { dataSummary += salarySection; } console.log(`[AI Planning] Data summary: ${dataSummary.length} chars, inc=кат:${incCatData.byCat.size}, exp=кат:${expCatData.byCat.size}${useSelectedExpenseFilter ? "(выбраны)" : ""}${salarySection ? ", +зарплаты" : ""}, sending to AI...`); const planningPrompt = cfg.systemPrompt || "Ты — финансовый аналитик. Проанализируй данные и верни JSON-массив прогнозных позиций."; const messages = [ { role: "system", content: planningPrompt }, { role: "user", content: dataSummary }, ]; const apiUrl = cfg.apiUrl.replace(/\/+$/, ""); const resp = await fetch(`${apiUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${cfg.apiKey}`, }, body: JSON.stringify({ model: cfg.model, messages, temperature: 0.1, max_tokens: 6000, }), signal: AbortSignal.timeout(180000), }); if (!resp.ok) { const errText = await resp.text(); const errorMsg = `Ошибка AI API (${resp.status}): ${errText.slice(0, 300)}`; saveLogEntry({ timestamp: new Date().toISOString(), chatId: "web", chatName: "Планирование", question: `[Аналитик] Анализ ${months} мес. (${dataSummary.length} символов)`, answer: errorMsg, sqlQueries: [], }); return res.status(502).json({ error: "Ошибка AI API" }); } const json = await resp.json() as any; const content = json.choices?.[0]?.message?.content || ""; let items: any[] = []; try { const cleanContent = content .replace(/```json\s*/gi, "") .replace(/```\s*/g, "") .replace(/[\s\S]*?<\/think>/g, "") .trim(); const jsonMatch = cleanContent.match(/\[[\s\S]*\]/); if (jsonMatch) { items = JSON.parse(jsonMatch[0]); } } catch (parseErr) { console.error("[AI Planning] Failed to parse AI response:", parseErr); saveLogEntry({ timestamp: new Date().toISOString(), chatId: "web", chatName: "Планирование", question: `[Аналитик] Анализ ${months} мес. (${dataSummary.length} символов)`, answer: `Ошибка парсинга: ${content.slice(0, 300)}`, sqlQueries: [], }); return res.status(502).json({ error: "AI вернул некорректный формат. Попробуйте ещё раз.", raw: content.slice(0, 500) }); } const validated = items .filter((it: any) => it && (it.type === "income" || it.type === "expense") && it.name && typeof it.monthlyAmount === "number") .map((it: any, idx: number) => ({ id: `ai-${Date.now()}-${idx}`, type: it.type, name: it.name, contract: it.contract || "", monthlyAmount: Math.round(it.monthlyAmount * 100) / 100, confidence: ["high", "medium", "low"].includes(it.confidence) ? it.confidence : "medium", recurring: typeof it.recurring === "boolean" ? it.recurring : false, reasoning: it.reasoning || "", enabled: true, source: "ai" as const, })); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`[AI Planning] Analyzed ${months} months, got ${validated.length} plan items in ${elapsed}s`); saveLogEntry({ timestamp: new Date().toISOString(), chatId: "web", chatName: "Планирование", question: `[Аналитик] Анализ ${months} мес. (${dataSummary.length} символов, кат.доходов: ${incCatData.byCat.size}, кат.расходов: ${expCatData.byCat.size}${useSelectedExpenseFilter ? " (выбраны)" : ""}, должников: ${(debtTop.rows as any[]).length})`, answer: `Найдено ${validated.length} позиций (${validated.filter(i => i.type === "income").length} доходных, ${validated.filter(i => i.type === "expense").length} расходных). Время: ${elapsed}с`, sqlQueries: [], }); res.json(validated); } catch (err: any) { console.error("[AI Planning]", err?.message); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const errorMsg = err?.message?.includes("abort") || err?.message?.includes("timeout") ? `Превышено время ожидания ответа от AI (${elapsed}с). Попробуйте ещё раз.` : `Ошибка анализа: ${err?.message || "неизвестная ошибка"}`; saveLogEntry({ timestamp: new Date().toISOString(), chatId: "web", chatName: "Планирование", question: `[Аналитик] Анализ (ошибка)`, answer: errorMsg, sqlQueries: [], }); if (err?.message?.includes("abort") || err?.message?.includes("timeout")) { return res.status(504).json({ error: errorMsg }); } res.status(500).json({ error: "Ошибка анализа" }); } }); // --- Google Auth routes --- app.get("/api/auth/google", (_req, res) => { try { const url = getAuthUrl(); res.redirect(url); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка авторизации Google" }); } }); app.get("/api/auth/google/callback", async (req, res) => { try { const code = req.query.code as string; if (!code) return res.status(400).send("Код авторизации не получен"); await handleCallback(code); res.redirect("/salary"); } catch (err) { console.error(err); res.status(500).send("Ошибка при авторизации Google. Попробуйте ещё раз."); } }); app.get("/api/auth/google/status", (_req, res) => { res.json(getAuthStatus()); }); app.post("/api/auth/google/logout", (_req, res) => { logout(); res.json({ success: true }); }); // --- Salary areas CRUD --- const SALARY_AREAS_PATH = path.join(process.cwd(), "data", "salary-areas.json"); interface SalaryArea { id: string; name: string; spreadsheetId: string; fioCol: string; bazaCol: string; kVydacheCol: string; } function colLetterToIndex(col: string): number { const c = col.toUpperCase(); let idx = 0; for (let i = 0; i < c.length; i++) { idx = idx * 26 + (c.charCodeAt(i) - 64); } return idx - 1; } function maxColLetter(...cols: string[]): string { let max = cols[0]; for (const c of cols) { if (colLetterToIndex(c) > colLetterToIndex(max)) max = c; } return max.toUpperCase(); } function loadAreas(): SalaryArea[] { try { if (fs.existsSync(SALARY_AREAS_PATH)) { const raw = JSON.parse(fs.readFileSync(SALARY_AREAS_PATH, "utf-8")); return raw.map((a: any) => ({ fioCol: "B", bazaCol: "F", kVydacheCol: "J", ...a, })); } } catch {} return []; } function saveAreas(areas: any[]): void { fs.writeFileSync(SALARY_AREAS_PATH, JSON.stringify(areas, null, 2)); } function extractSpreadsheetId(url: string): string | null { const m = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/); return m ? m[1] : null; } app.get("/api/salary/areas", (_req, res) => { res.json(loadAreas()); }); const COL_PATTERN = /^[A-Za-z]{1,2}$/; app.post("/api/salary/areas", (req, res) => { try { const { name, spreadsheetUrl, fioCol = "B", bazaCol = "F", kVydacheCol = "J" } = req.body || {}; if (!name || !spreadsheetUrl) return res.status(400).json({ error: "Укажите название участка и ссылку на таблицу" }); const spreadsheetId = extractSpreadsheetId(spreadsheetUrl); if (!spreadsheetId) return res.status(400).json({ error: "Неверная ссылка на Google Таблицу" }); if (!COL_PATTERN.test(fioCol) || !COL_PATTERN.test(bazaCol) || !COL_PATTERN.test(kVydacheCol)) { return res.status(400).json({ error: "Столбцы должны быть буквами (A-Z)" }); } const areas = loadAreas(); const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); areas.push({ id, name, spreadsheetId, fioCol: fioCol.toUpperCase(), bazaCol: bazaCol.toUpperCase(), kVydacheCol: kVydacheCol.toUpperCase() }); saveAreas(areas); res.json({ id, name, spreadsheetId, fioCol: fioCol.toUpperCase(), bazaCol: bazaCol.toUpperCase(), kVydacheCol: kVydacheCol.toUpperCase() }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка при добавлении участка" }); } }); app.patch("/api/salary/areas/:id", (req, res) => { try { const areas = loadAreas(); const area = areas.find(a => a.id === req.params.id); if (!area) return res.status(404).json({ error: "Участок не найден" }); const { fioCol, bazaCol, kVydacheCol } = req.body || {}; if (fioCol !== undefined) { if (!COL_PATTERN.test(fioCol)) return res.status(400).json({ error: "Столбец ФИО должен быть буквой (A-Z)" }); area.fioCol = fioCol.toUpperCase(); } if (bazaCol !== undefined) { if (!COL_PATTERN.test(bazaCol)) return res.status(400).json({ error: "Столбец База должен быть буквой (A-Z)" }); area.bazaCol = bazaCol.toUpperCase(); } if (kVydacheCol !== undefined) { if (!COL_PATTERN.test(kVydacheCol)) return res.status(400).json({ error: "Столбец К выдаче должен быть буквой (A-Z)" }); area.kVydacheCol = kVydacheCol.toUpperCase(); } saveAreas(areas); res.json(area); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка при обновлении участка" }); } }); app.delete("/api/salary/areas/:id", (req, res) => { try { let areas = loadAreas(); areas = areas.filter(a => a.id !== req.params.id); saveAreas(areas); res.json({ success: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка при удалении участка" }); } }); // --- opneof table init --- let opneofReady = false; async function tryInitOpneof(): Promise { if (opneofReady) return true; try { await query(` CREATE TABLE IF NOT EXISTS opneof ( id SERIAL PRIMARY KEY, area_id TEXT NOT NULL, area_name TEXT NOT NULL, month TEXT NOT NULL, fio TEXT NOT NULL, baza NUMERIC NOT NULL DEFAULT 0, k_vydache NUMERIC NOT NULL DEFAULT 0, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ) `); await query(` CREATE UNIQUE INDEX IF NOT EXISTS opneof_ukey ON opneof (area_id, month, fio) `); opneofReady = true; console.log("[opneof] Таблица готова"); return true; } catch (err: any) { console.error("[opneof] Ошибка создания таблицы:", err?.message); return false; } } tryInitOpneof(); async function ensureOpneof(): Promise { return tryInitOpneof(); } // ── opof_manual_areas: persistent manual FIO → area overrides ────────────── let opofManualAreasReady = false; async function ensureOpofManualAreas(): Promise { if (opofManualAreasReady) return true; try { await query(` CREATE TABLE IF NOT EXISTS opof_manual_areas ( fio_letters TEXT PRIMARY KEY, fio_display TEXT NOT NULL, area_id TEXT NOT NULL, area_name TEXT NOT NULL, canonical_fio TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); opofManualAreasReady = true; console.log("[opof_manual_areas] Таблица готова"); return true; } catch (err: any) { console.error("[opof_manual_areas] Ошибка создания таблицы:", err?.message); return false; } } ensureOpofManualAreas(); async function opneofHasData(): Promise { try { if (!(await ensureOpneof())) return false; const r = await query("SELECT 1 FROM opneof WHERE deleted_at IS NULL LIMIT 1"); return r.rows.length > 0; } catch { return false; } } async function readNeofFromDb(areaId?: string): Promise { let sql = `SELECT area_id, area_name, month, fio, baza, k_vydache FROM opneof WHERE deleted_at IS NULL`; const params: unknown[] = []; if (areaId) { params.push(areaId); sql += ` AND area_id = $${params.length}`; } sql += ` ORDER BY area_id, month, fio`; const r = await query(sql, params); const areasMap = new Map }>(); for (const row of r.rows) { if (!areasMap.has(row.area_id)) { areasMap.set(row.area_id, { id: row.area_id, name: row.area_name, monthsMap: new Map() }); } const a = areasMap.get(row.area_id)!; if (!a.monthsMap.has(row.month)) a.monthsMap.set(row.month, []); a.monthsMap.get(row.month)!.push({ fio: row.fio, baza: Number(row.baza), kVydache: Number(row.k_vydache), }); } const areas = [...areasMap.values()].map(a => ({ id: a.id, name: a.name, months: [...a.monthsMap.entries()].map(([month, employees]) => ({ month, employees })), })); return { areas, source: "db" }; } // --- Salary data fetch from Google Sheets --- const MONTH_PATTERN = /^(\d{1,2})\.(\d{2})$/; function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } async function withRetry(fn: () => Promise, maxRetries = 4, baseDelayMs = 1000): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (err: any) { const msg = String(err?.message || ""); const status = err?.statusCode || err?.code || err?.response?.status; const isQuota = msg.includes("Quota exceeded") || msg.includes("quota") || status === 429 || msg.includes("429") || msg.includes("RATE_LIMIT") || msg.includes("rate limit"); if (!isQuota || attempt === maxRetries) throw err; const delayMs = baseDelayMs * Math.pow(2, attempt); console.log(`[opneof] retry ${attempt + 1}/${maxRetries} after ${delayMs}ms — ${msg.slice(0, 120)}`); await sleep(delayMs); } } throw new Error("withRetry: unreachable"); } async function fetchNeofFromSheets(targetAreas: SalaryArea[]): Promise { const results: any[] = []; for (const area of targetAreas) { try { const sheetNames = await withRetry(() => getSheetNames(area.spreadsheetId)); const monthSheets = sheetNames .filter(name => MONTH_PATTERN.test(name)) .sort((a, b) => { const [, am, ay] = a.match(MONTH_PATTERN)!; const [, bm, by] = b.match(MONTH_PATTERN)!; return (Number(ay) * 12 + Number(am)) - (Number(by) * 12 + Number(bm)); }); const months: { month: string; employees: any[] }[] = []; for (const sheetName of monthSheets) { const fioIdx = colLetterToIndex(area.fioCol || "B"); const bazaIdx = colLetterToIndex(area.bazaCol || "F"); const kVydacheIdx = colLetterToIndex(area.kVydacheCol || "J"); const lastCol = maxColLetter(area.fioCol || "B", area.bazaCol || "F", area.kVydacheCol || "J"); const rows = await withRetry(() => getSheetData(area.spreadsheetId, `'${sheetName}'!A4:${lastCol}500`)); const employees = rows .filter(row => row && row[fioIdx] && String(row[fioIdx]).trim()) .map(row => { const rawFio = String(row[fioIdx] || ""); const fio = rawFio.replace(/[0-9]/g, "").replace(/[^\p{L}\s\-().]/gu, "").replace(/\s+/g, " ").trim(); if (!fio) return null; const baza = Number(String(row[bazaIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; const kVydache = Number(String(row[kVydacheIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; return { fio, baza, kVydache }; }) .filter(Boolean); months.push({ month: sheetName, employees }); await sleep(200); } results.push({ id: area.id, name: area.name, months: months.filter(m => m.employees.length > 0) }); } catch (err: any) { console.error(`Error fetching area ${area.name}:`, err?.message); results.push({ id: area.id, name: area.name, months: [], error: "Ошибка загрузки" }); } await sleep(600); } return results; } app.get("/api/salary/neof", async (req, res) => { try { const areaId = req.query.areaId as string | undefined; const hasDb = await opneofHasData(); if (hasDb) { const result = await readNeofFromDb(areaId); return res.json(result); } const status = getAuthStatus(); if (!status.loggedIn) return res.status(401).json({ error: "Не авторизован в Google", authRequired: true }); const areas = loadAreas(); const targetAreas = areaId ? areas.filter(a => a.id === areaId) : areas; if (targetAreas.length === 0) return res.json({ areas: [], source: "sheets" }); const results = await fetchNeofFromSheets(targetAreas); res.json({ areas: results, source: "sheets" }); } catch (err: any) { if (err?.message?.includes("авторизован") || err?.message?.includes("Токен")) { return res.status(401).json({ error: err.message, authRequired: true }); } console.error(err); res.status(500).json({ error: "Ошибка загрузки данных зарплат" }); } }); // --- ОФ tab: opof table --- // GET /api/salary/zof/areas — unique areas from opneof for the assign dropdown app.get("/api/salary/zof/areas", async (_req, res) => { try { if (!(await ensureOpneof())) return res.json({ areas: [] }); const r = await query( `SELECT DISTINCT area_id, area_name FROM opneof WHERE deleted_at IS NULL ORDER BY area_name` ); const areas = r.rows.map((row: any) => ({ id: row.area_id, name: row.area_name })); res.json({ areas }); } catch (err: any) { if (isConnectionError(err)) return res.json({ areas: [] }); console.error("[zof/areas]", err); res.status(500).json({ error: "Ошибка загрузки участков" }); } }); // POST /api/salary/zof/assign — upsert a manual FIO → area override app.post("/api/salary/zof/assign", async (req, res) => { try { await ensureOpofManualAreas(); const { fioLetters, fioDisplay, areaId, areaName, canonicalFio } = req.body || {}; if (!fioLetters || !areaId || !areaName) { return res.status(400).json({ error: "fioLetters, areaId, areaName обязательны" }); } await query(` INSERT INTO opof_manual_areas (fio_letters, fio_display, area_id, area_name, canonical_fio) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (fio_letters) DO UPDATE SET fio_display = EXCLUDED.fio_display, area_id = EXCLUDED.area_id, area_name = EXCLUDED.area_name, canonical_fio = EXCLUDED.canonical_fio, created_at = NOW() `, [fioLetters, fioDisplay || fioLetters, areaId, areaName, canonicalFio || ""]); res.json({ ok: true }); } catch (err: any) { console.error("[zof/assign]", err); res.status(500).json({ error: "Ошибка сохранения назначения" }); } }); // DELETE /api/salary/zof/assign/:fioLetters — remove a manual override app.delete("/api/salary/zof/assign/:fioLetters", async (req, res) => { try { await ensureOpofManualAreas(); await query( `DELETE FROM opof_manual_areas WHERE fio_letters = $1`, [req.params.fioLetters] ); res.json({ ok: true }); } catch (err: any) { console.error("[zof/assign/delete]", err); res.status(500).json({ error: "Ошибка удаления назначения" }); } }); app.get("/api/salary/zof", async (_req, res) => { try { // Format periodregistrator → "MM.YY" // The column may be a date/timestamp or a string like "01.2026" / "2026-01-01" const opofRows = await query(` SELECT sotrudnik, nachislenobezndfl, summandfl, strahov, periodregistrator FROM opof ORDER BY periodregistrator, sotrudnik `); // Keep only Cyrillic letters, lowercase — for fuzzy FIO matching function lettersOnly(s: string): string { return s.toLowerCase().replace(/[^а-яё]/g, ""); } // Levenshtein distance (edit distance) function levenshtein(a: string, b: string): number { const m = a.length, n = b.length; if (m === 0) return n; if (n === 0) return m; const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i); for (let i = 1; i <= m; i++) { let prev = dp[0]; dp[0] = i; for (let j = 1; j <= n; j++) { const tmp = dp[j]; dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]); prev = tmp; } } return dp[n]; } // Similarity score 0..1: 1 = identical, 0 = completely different function similarity(a: string, b: string): number { const maxLen = Math.max(a.length, b.length); if (maxLen === 0) return 1; return 1 - levenshtein(a, b) / maxLen; } // Load manual overrides (opof_manual_areas) — persist across restarts await ensureOpofManualAreas(); const manualRows = await query(`SELECT fio_letters, fio_display, area_id, area_name, canonical_fio FROM opof_manual_areas`).catch(() => ({ rows: [] })); const manualMap = new Map(); for (const row of manualRows.rows) { manualMap.set(String(row.fio_letters), { id: String(row.area_id), name: String(row.area_name), canonicalFio: String(row.canonical_fio || row.fio_display || ""), }); } // Build global FIO index from opneof (no month constraint) const neofRows = await query( `SELECT DISTINCT fio, area_id, area_name FROM opneof WHERE deleted_at IS NULL` ); const neofIndex: Array<{ letters: string; id: string; name: string; canonicalFio: string }> = []; for (const row of neofRows.rows) { const fio = String(row.fio || "").trim(); const letters = lettersOnly(fio); if (letters) { neofIndex.push({ letters, id: row.area_id, name: row.area_name, canonicalFio: fio }); } } // Find best match in neofIndex for a given letters string (threshold 0.9) function findBestMatch(srcLetters: string): { id: string; name: string; canonicalFio: string } | null { if (!srcLetters) return null; let best: { id: string; name: string; canonicalFio: string } | null = null; let bestSim = 0; for (const entry of neofIndex) { const sim = similarity(srcLetters, entry.letters); if (sim > bestSim) { bestSim = sim; best = entry; } } return bestSim >= 0.9 ? best : null; } function formatPeriod(raw: unknown): string { if (raw == null) return "??.??"; const s = String(raw).trim(); // Already in MM.YY or MM.YYYY format const mmYY = s.match(/^(\d{1,2})\.(\d{2})$/); if (mmYY) return `${mmYY[1].padStart(2, "0")}.${mmYY[2]}`; const mmYYYY = s.match(/^(\d{1,2})\.(\d{4})$/); if (mmYYYY) return `${mmYYYY[1].padStart(2, "0")}.${mmYYYY[2].slice(2)}`; // ISO date or timestamp: 2026-01-01 or 2026-01-01T... const isoDate = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (isoDate) return `${isoDate[2]}.${isoDate[1].slice(2)}`; // Try JS Date const d = new Date(s); if (!isNaN(d.getTime())) { const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = String(d.getFullYear()).slice(2); return `${mm}.${yy}`; } return s; } // Group by area → month → employees const areasMap = new Map; }>(); const ensureArea = (id: string, name: string) => { if (!areasMap.has(id)) areasMap.set(id, { id, name, monthsMap: new Map() }); return areasMap.get(id)!; }; for (const row of opofRows.rows) { const rawFio = String(row.sotrudnik || "").trim(); const period = formatPeriod(row.periodregistrator); const srcLetters = lettersOnly(rawFio); // Priority: manual override > fuzzy match > unassigned const manual = manualMap.get(srcLetters); const fuzzy = manual ? null : findBestMatch(srcLetters); const resolved = manual ?? fuzzy; const displayFio = resolved?.canonicalFio || rawFio; const area = ensureArea( resolved?.id ?? "unassigned", resolved?.name ?? "Нераспределённые" ); if (!area.monthsMap.has(period)) area.monthsMap.set(period, []); area.monthsMap.get(period)!.push({ fio: displayFio, fioLetters: srcLetters, nachislenobezndfl: Number(row.nachislenobezndfl) || 0, summandfl: Number(row.summandfl) || 0, strahov: Number(row.strahov) || 0, }); } // Sort: assigned areas first, unassigned last const areas = [...areasMap.values()] .sort((a, b) => { if (a.id === "unassigned") return 1; if (b.id === "unassigned") return -1; return a.name.localeCompare(b.name, "ru"); }) .map(a => ({ id: a.id, name: a.name, months: [...a.monthsMap.entries()] .sort((x, y) => { const [xm, xy] = x[0].split(".").map(Number); const [ym, yy] = y[0].split(".").map(Number); return (yy * 12 + ym) - (xy * 12 + xm); }) .map(([month, employees]) => ({ month, employees })), })); res.json({ areas }); } catch (err: any) { if (isConnectionError(err)) { return res.json({ areas: [] }); } console.error("[zof]", err); res.status(500).json({ error: "Ошибка загрузки данных ОФ" }); } }); app.get("/api/salary/db-status", async (_req, res) => { try { if (!(await ensureOpneof())) return res.json({ totalRows: 0, lastSync: null, areas: [], monthCount: 0 }); const countRes = await query("SELECT COUNT(*) as cnt FROM opneof WHERE deleted_at IS NULL"); const lastSyncRes = await query("SELECT MAX(synced_at) as last_sync FROM opneof"); const areasRes = await query("SELECT DISTINCT area_name FROM opneof WHERE deleted_at IS NULL ORDER BY area_name"); const monthsRes = await query("SELECT COUNT(DISTINCT month) as cnt FROM opneof WHERE deleted_at IS NULL"); res.json({ totalRows: Number(countRes.rows[0]?.cnt || 0), lastSync: lastSyncRes.rows[0]?.last_sync || null, areas: areasRes.rows.map(r => r.area_name), monthCount: Number(monthsRes.rows[0]?.cnt || 0), }); } catch (err: any) { if (isConnectionError(err)) { return res.json({ totalRows: 0, lastSync: null, areas: [], monthCount: 0 }); } handleApiError(res, err, "Ошибка получения статуса opneof"); } }); let syncInProgress = false; app.post("/api/salary/sync", async (_req, res) => { try { if (syncInProgress) return res.status(409).json({ error: "Синхронизация уже выполняется" }); syncInProgress = true; const status = getAuthStatus(); if (!status.loggedIn) { syncInProgress = false; return res.status(401).json({ error: "Не авторизован в Google", authRequired: true }); } if (!(await ensureOpneof())) { syncInProgress = false; return res.status(503).json({ error: "Таблица opneof не готова" }); } const areas = loadAreas(); if (areas.length === 0) { syncInProgress = false; return res.json({ added: 0, updated: 0, deleted: 0, total: 0 }); } const sheetsData = await fetchNeofFromSheets(areas); const failedAreas = sheetsData.filter(a => a.error); if (failedAreas.length === sheetsData.length) { syncInProgress = false; const firstErr = failedAreas[0]?.error || "Неизвестная ошибка"; if (firstErr.includes("авторизован") || firstErr.includes("Токен") || firstErr.includes("401")) { return res.status(401).json({ error: "Ошибка авторизации Google: " + firstErr, authRequired: true }); } return res.status(500).json({ error: "Все участки вернули ошибку: " + firstErr }); } const now = new Date().toISOString(); const client = await getPoolClient(); try { await client.query("BEGIN"); let added = 0, updated = 0, deleted = 0; const errors: string[] = failedAreas.map(a => `${a.name}: ${a.error}`); const sheetsKeys = new Set(); for (const areaData of sheetsData) { if (areaData.error) continue; for (const monthData of areaData.months) { for (const emp of monthData.employees) { sheetsKeys.add(`${areaData.id}||${monthData.month}||${emp.fio}`); const r = await client.query( `INSERT INTO opneof (area_id, area_name, month, fio, baza, k_vydache, synced_at, deleted_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL) ON CONFLICT (area_id, month, fio) DO UPDATE SET baza = EXCLUDED.baza, k_vydache = EXCLUDED.k_vydache, area_name = EXCLUDED.area_name, synced_at = EXCLUDED.synced_at, deleted_at = NULL WHERE opneof.baza IS DISTINCT FROM EXCLUDED.baza OR opneof.k_vydache IS DISTINCT FROM EXCLUDED.k_vydache OR opneof.area_name IS DISTINCT FROM EXCLUDED.area_name OR opneof.deleted_at IS NOT NULL RETURNING (xmax = 0) AS inserted`, [areaData.id, areaData.name, monthData.month, emp.fio, emp.baza, emp.kVydache, now] ); if (r.rows.length === 0) continue; if (r.rows[0]?.inserted) added++; else updated++; } } } for (const areaData of sheetsData) { if (areaData.error) continue; const existingRows = await client.query( "SELECT id, area_id, month, fio FROM opneof WHERE area_id = $1 AND deleted_at IS NULL", [areaData.id] ); for (const row of existingRows.rows) { if (!sheetsKeys.has(`${row.area_id}||${row.month}||${row.fio}`)) { await client.query("UPDATE opneof SET deleted_at = $1, synced_at = $1 WHERE id = $2", [now, row.id]); deleted++; } } } await client.query("COMMIT"); client.release(); const totalRes = await query("SELECT COUNT(*) as cnt FROM opneof WHERE deleted_at IS NULL"); const total = Number(totalRes.rows[0]?.cnt || 0); console.log(`[opneof sync] added=${added} updated=${updated} deleted=${deleted} total=${total} errors=${errors.length}`); syncInProgress = false; res.json({ added, updated, deleted, total, errors: errors.length > 0 ? errors : undefined }); } catch (txErr) { await client.query("ROLLBACK").catch(() => {}); client.release(); throw txErr; } } catch (err: any) { syncInProgress = false; if (err?.message?.includes("авторизован") || err?.message?.includes("Токен")) { return res.status(401).json({ error: err.message, authRequired: true }); } console.error("[salary sync] Error:", err); res.status(500).json({ error: "Ошибка синхронизации" }); } }); app.get("/api/planning/salary-monthly", async (_req, res) => { try { const hasDb = await opneofHasData(); if (hasDb) { const r = await query(` SELECT month, SUM(k_vydache) as total FROM opneof WHERE deleted_at IS NULL GROUP BY month ORDER BY month `); const MONTH_NAMES_MAP: Record = { "1": "01", "2": "02", "3": "03", "4": "04", "5": "05", "6": "06", "7": "07", "8": "08", "9": "09", "10": "10", "11": "11", "12": "12", }; const result = r.rows.map(row => { const m = row.month.match(/^(\d{1,2})\.(\d{2})$/); if (!m) return null; const fullYear = Number(m[2]) < 50 ? 2000 + Number(m[2]) : 1900 + Number(m[2]); const period = `${fullYear}-${(MONTH_NAMES_MAP[m[1]] || m[1])}`; return { period, total: Math.round(Number(row.total) * 100) / 100 }; }).filter(Boolean).sort((a: any, b: any) => a.period.localeCompare(b.period)); return res.json(result); } const status = getAuthStatus(); if (!status.loggedIn) return res.json([]); const areas = loadAreas(); if (areas.length === 0) return res.json([]); const result = await cachedQuery("planning-salary-monthly", TTL_SHORT, async () => { const monthTotals = new Map(); for (const area of areas) { try { const sheetNames = await getSheetNames(area.spreadsheetId); const monthSheets = sheetNames.filter(name => MONTH_PATTERN.test(name)); for (const sheetName of monthSheets) { const [, mm, yy] = sheetName.match(MONTH_PATTERN)!; const fullYear = Number(yy) < 50 ? 2000 + Number(yy) : 1900 + Number(yy); const period = `${fullYear}-${String(mm).padStart(2, "0")}`; const kVydacheIdx = colLetterToIndex(area.kVydacheCol || "J"); const fioIdx = colLetterToIndex(area.fioCol || "B"); const lastCol = maxColLetter(area.fioCol || "B", area.bazaCol || "F", area.kVydacheCol || "J"); const rows = await getSheetData(area.spreadsheetId, `'${sheetName}'!A4:${lastCol}500`); let total = 0; for (const row of rows) { if (!row || !row[fioIdx] || !String(row[fioIdx]).trim()) continue; const fio = String(row[fioIdx] || "").replace(/[0-9]/g, "").replace(/[^\p{L}\s\-().]/gu, "").replace(/\s+/g, " ").trim(); if (!fio) continue; total += Number(String(row[kVydacheIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; } monthTotals.set(period, (monthTotals.get(period) || 0) + total); } } catch (err: any) { console.error(`[salary-monthly] Error area ${area.name}:`, err?.message); } } return [...monthTotals.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([period, total]) => ({ period, total: Math.round(total * 100) / 100 })); }); res.json(result); } catch (err: any) { console.error("[salary-monthly] Error:", err?.message); res.json([]); } }); app.get("/api/planning/receivables", async (_req, res) => { try { const result = await cachedQuery("planning-receivables", TTL_SHORT, async () => { const cutoffDate = new Date(); cutoffDate.setMonth(cutoffDate.getMonth() - 6); const cutoff6m = cutoffDate.toISOString().substring(0, 10); const [summaryRes, byKontragentRes, byClassRes, dynamicsRes, debtDetailRes, incomeByKontragentRes] = await Promise.all([ query(`SELECT COALESCE(SUM(summadolga::numeric),0) as total_debt, COALESCE(SUM(summaprosrocheno::numeric),0) as total_overdue, COUNT(DISTINCT kontragent) as kontragent_count FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()})`), query(`SELECT kontragent, COALESCE(SUM(summadokumenta::numeric),0) as doc_total, COALESCE(SUM(summaoplacheno::numeric),0) as paid_total, COALESCE(SUM(summadolga::numeric),0) as debt_total, COALESCE(SUM(summaprosrocheno::numeric),0) as overdue_total, MAX(COALESCE(dneiprosrocheno::numeric,0)) as max_days_overdue FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()}) GROUP BY kontragent ORDER BY debt_total DESC LIMIT 30`), query(`SELECT CASE WHEN klassprosrochki IS NULL OR klassprosrochki = '0' THEN 'Не просрочено' WHEN klassprosrochki = '1-30' THEN '1-30 дней' WHEN klassprosrochki = '31-60' THEN '31-60 дней' WHEN klassprosrochki = '61-90' THEN '61-90 дней' WHEN klassprosrochki = '91-365' THEN CASE WHEN COALESCE(dneiprosrocheno::numeric,0) <= 120 THEN '91-120 дней' WHEN COALESCE(dneiprosrocheno::numeric,0) <= 180 THEN '121-180 дней' WHEN COALESCE(dneiprosrocheno::numeric,0) <= 270 THEN '181-270 дней' ELSE '271-365 дней' END END as cls, COALESCE(SUM(summadolga::numeric),0) as total FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()}) AND (klassprosrochki IS NULL OR klassprosrochki != '> 365') GROUP BY cls ORDER BY total DESC`), query(`SELECT TO_CHAR(period, 'YYYY-MM') as month, COALESCE(SUM(summadolga::numeric),0) as total_debt, COALESCE(SUM(summaprosrocheno::numeric),0) as total_overdue FROM ${getDebtTable()} WHERE period >= '${cutoff6m}'::date GROUP BY TO_CHAR(period, 'YYYY-MM') ORDER BY month`), query(`SELECT kontragent, dogovor, COALESCE(summadokumenta::numeric,0) as doc_amount, COALESCE(summaoplacheno::numeric,0) as paid_amount, COALESCE(summadolga::numeric,0) as debt_amount, COALESCE(summaprosrocheno::numeric,0) as overdue_amount, COALESCE(dneiprosrocheno::numeric,0) as days_overdue, dokumentraschetov FROM ${getDebtTable()} WHERE period = (SELECT MAX(period) FROM ${getDebtTable()}) AND summadolga::numeric > 0 ORDER BY debt_amount DESC LIMIT 100`), query(`SELECT zakazchik as kontragent, SUM(summa::numeric) as income_total FROM ${getIncomeEffectiveView()} WHERE data >= '${cutoff6m}'::date AND zakazchik IS NOT NULL AND zakazchik != '0' GROUP BY zakazchik`), ]); const summary = { totalDebt: Number(summaryRes.rows[0]?.total_debt || 0), totalOverdue: Number(summaryRes.rows[0]?.total_overdue || 0), kontragentCount: Number(summaryRes.rows[0]?.kontragent_count || 0), }; const incomeMap = new Map(); for (const r of incomeByKontragentRes.rows as any[]) { incomeMap.set(r.kontragent, Number(r.income_total)); } const byKontragent = (byKontragentRes.rows as any[]).map((r: any) => ({ kontragent: r.kontragent, docTotal: Number(r.doc_total), paidTotal: Number(r.paid_total), debtTotal: Number(r.debt_total), overdueTotal: Number(r.overdue_total), maxDaysOverdue: Number(r.max_days_overdue), incomeReceived: incomeMap.get(r.kontragent) || 0, })); const byClass = (byClassRes.rows as any[]).map((r: any) => ({ cls: r.cls, total: Number(r.total), })); const dynamics = (dynamicsRes.rows as any[]).map((r: any) => ({ month: r.month, totalDebt: Number(r.total_debt), totalOverdue: Number(r.total_overdue), })); const expectedPayments = (debtDetailRes.rows as any[]).map((r: any) => ({ kontragent: r.kontragent, dogovor: r.dogovor || "", document: r.dokumentraschetov || "", docAmount: Number(r.doc_amount), paidAmount: Number(r.paid_amount), debtAmount: Number(r.debt_amount), overdueAmount: Number(r.overdue_amount), daysOverdue: Number(r.days_overdue), })); return { summary, byKontragent, byClass, dynamics, expectedPayments }; }); res.json(result); } catch (err: any) { console.error("[Planning Receivables]", err); res.status(500).json({ error: "Ошибка загрузки данных дебиторки" }); } }); // --- Calc sections CRUD --- const CALC_SECTIONS_PATH = path.join(process.cwd(), "data", "calc-sections.json"); interface CalcSection { id: string; name: string; spreadsheetUrl: string; spreadsheetId: string; } function loadCalcSections(): CalcSection[] { try { if (fs.existsSync(CALC_SECTIONS_PATH)) { return JSON.parse(fs.readFileSync(CALC_SECTIONS_PATH, "utf-8")); } } catch {} return []; } function saveCalcSections(sections: CalcSection[]): void { fs.writeFileSync(CALC_SECTIONS_PATH, JSON.stringify(sections, null, 2)); } app.get("/api/calculations/sections", (_req, res) => { res.json(loadCalcSections()); }); app.post("/api/calculations/sections", (req, res) => { try { const { name, spreadsheetUrl } = req.body || {}; if (!name || !spreadsheetUrl) return res.status(400).json({ error: "Укажите название участка и ссылку на таблицу" }); const spreadsheetId = extractSpreadsheetId(spreadsheetUrl); if (!spreadsheetId) return res.status(400).json({ error: "Неверная ссылка на Google Таблицу" }); try { const parsed = new URL(spreadsheetUrl); if (parsed.hostname !== "docs.google.com" || parsed.protocol !== "https:") { return res.status(400).json({ error: "Ссылка должна вести на docs.google.com" }); } } catch { return res.status(400).json({ error: "Неверный формат ссылки" }); } const canonicalUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`; const sections = loadCalcSections(); const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); sections.push({ id, name, spreadsheetUrl: canonicalUrl, spreadsheetId }); saveCalcSections(sections); res.json({ id, name, spreadsheetUrl: canonicalUrl, spreadsheetId }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка при добавлении участка" }); } }); app.delete("/api/calculations/sections/:id", (req, res) => { try { const sections = loadCalcSections(); const idx = sections.findIndex(s => s.id === req.params.id); if (idx === -1) return res.status(404).json({ error: "Участок не найден" }); sections.splice(idx, 1); saveCalcSections(sections); res.json({ ok: true }); } catch (err) { console.error(err); res.status(500).json({ error: "Ошибка при удалении участка" }); } }); app.get("/api/audit/integrity", async (_req, res) => { try { const result = await runIntegrityAudit(); res.json(result); } catch (err) { handleApiError(res, err, "Ошибка при аудите данных"); } }); app.get("/api/overrides", async (_req, res) => { try { const type = _req.query.type as string; if (type === "income") { const rows = await getAllIncomeOverrides(); res.json(Object.fromEntries(rows.map(r => [r.id, r.categoryId]))); } else if (type === "expense") { const rows = await getAllExpenseOverrides(); res.json(Object.fromEntries(rows.map(r => [r.id, r.categoryId]))); } else { const [incRows, expRows] = await Promise.all([getAllIncomeOverrides(), getAllExpenseOverrides()]); res.json({ income: Object.fromEntries(incRows.map(r => [r.id, r.categoryId])), expense: Object.fromEntries(expRows.map(r => [r.id, r.categoryId])), }); } } catch (err) { handleApiError(res, err, "Ошибка при загрузке переопределений"); } }); app.post("/api/overrides", async (req, res) => { try { const { type, recordId, categoryId } = req.body; if (!type || !recordId || !categoryId) { return res.status(400).json({ error: "type, recordId, categoryId обязательны" }); } if (type !== "expense" && type !== "income") { return res.status(400).json({ error: "type must be expense or income" }); } const rid = String(recordId).trim(); const cid = String(categoryId).trim(); if (!rid || !cid || rid.length > 100 || cid.length > 100) { return res.status(400).json({ error: "Некорректные recordId или categoryId" }); } const cats = type === "income" ? loadCategories() : loadExpenseCategories(); const foundCat = findCategoryById(cats, cid); if (!foundCat) { return res.status(400).json({ error: "Категория не найдена" }); } if (type === "income") { const overrideCols = extractColumnValuesFromCategoryPath(foundCat.path, INCOME_OVERRIDE_COLS); await saveIncomeOverride(rid, overrideCols, cid); } else { const overrideCols = extractColumnValuesFromCategoryPath(foundCat.path, EXPENSE_OVERRIDE_COLS); await saveExpenseOverride(rid, overrideCols, cid); } invalidateServerCache(); scheduleTreeRebuild(); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при сохранении переопределения"); } }); app.delete("/api/overrides/:type/:recordId", async (req, res) => { try { const { type, recordId } = req.params; if (type !== "expense" && type !== "income") { return res.status(400).json({ error: "type must be expense or income" }); } if (type === "income") { await deleteIncomeOverride(recordId); } else { await deleteExpenseOverride(recordId); } invalidateServerCache(); scheduleTreeRebuild(); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при удалении переопределения"); } }); app.post("/api/admin/rebuild-tree-tables", async (_req, res) => { try { scheduleTreeRebuild(); res.json({ ok: true, message: "Перестройка таблиц деревьев запущена в фоне" }); } catch (err) { handleApiError(res, err, "Ошибка запуска перестройки"); } }); app.get("/api/tags", async (_req, res) => { try { const result = await query(`SELECT id, name, color FROM node_tags ORDER BY name`); res.json(result.rows); } catch (err) { handleApiError(res, err, "Ошибка при загрузке тегов"); } }); app.post("/api/tags", async (req, res) => { try { const { name, color } = req.body; if (!name || !color) return res.status(400).json({ error: "name и color обязательны" }); const id = randomUUID(); await query(`INSERT INTO node_tags (id, name, color) VALUES ($1, $2, $3)`, [id, String(name).trim(), String(color)]); res.json({ id, name: String(name).trim(), color: String(color) }); } catch (err) { handleApiError(res, err, "Ошибка при создании тега"); } }); app.delete("/api/tags/:id", async (req, res) => { try { const { id } = req.params; await query(`DELETE FROM node_tag_assignments WHERE tag_id = $1`, [id]); await query(`DELETE FROM node_tags WHERE id = $1`, [id]); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при удалении тега"); } }); app.get("/api/tag-assignments", async (req, res) => { try { const context = req.query.context as string | undefined; if (context && context !== "income" && context !== "expense") { return res.status(400).json({ error: "context должен быть income или expense" }); } const rawNodeIds = req.query["nodeIds[]"]; const nodeIds: string[] = rawNodeIds ? (Array.isArray(rawNodeIds) ? rawNodeIds : [rawNodeIds]).map(String) : []; const conditions: string[] = []; const params: unknown[] = []; if (context) { params.push(context); conditions.push(`context = $${params.length}`); } if (nodeIds.length > 0) { params.push(nodeIds); conditions.push(`node_id = ANY($${params.length})`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const result = await query( `SELECT id, node_id as "nodeId", tag_id as "tagId" FROM node_tag_assignments ${where}`, params ); res.json(result.rows); } catch (err) { handleApiError(res, err, "Ошибка при загрузке назначений тегов"); } }); app.post("/api/tag-assignments", async (req, res) => { try { const { nodeId, tagId, context } = req.body; if (!nodeId || !tagId || !context) return res.status(400).json({ error: "nodeId, tagId, context обязательны" }); const id = randomUUID(); await query( `INSERT INTO node_tag_assignments (id, node_id, tag_id, context) VALUES ($1, $2, $3, $4) ON CONFLICT (node_id, tag_id, context) DO NOTHING`, [id, String(nodeId), String(tagId), String(context)] ); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при назначении тега"); } }); app.delete("/api/tag-assignments", async (req, res) => { try { const { nodeId, tagId, context } = req.body; if (!nodeId || !tagId || !context) return res.status(400).json({ error: "nodeId, tagId, context обязательны" }); await query( `DELETE FROM node_tag_assignments WHERE node_id = $1 AND tag_id = $2 AND context = $3`, [String(nodeId), String(tagId), String(context)] ); res.json({ ok: true }); } catch (err) { handleApiError(res, err, "Ошибка при снятии тега"); } }); app.get("/api/admin/tree-tables", async (_req, res) => { try { const incConfig = loadIncomeAutoDetectConfig(); const expConfig = loadAutoDetectConfig(); const allTables: { name: string; type: string; isPrimary: boolean; exists: boolean }[] = []; const incomeTrees = [{ name: "Ручные категории", isPrimary: true }, { name: "Нераспределённые", isPrimary: false }, ...incConfig.trees.map(t => ({ name: t.name, isPrimary: t.isPrimary ?? true }))]; const expenseTrees = [{ name: "Ручные категории", isPrimary: true }, { name: "Нераспределённые", isPrimary: false }, ...expConfig.trees.map(t => ({ name: t.name, isPrimary: t.isPrimary ?? true }))]; for (const t of incomeTrees) { const tableName = getTreeTableName("income", t.name); allTables.push({ name: tableName, type: "income", isPrimary: t.isPrimary, exists: await treeTableExists(tableName) }); } for (const t of expenseTrees) { const tableName = getTreeTableName("expense", t.name); allTables.push({ name: tableName, type: "expense", isPrimary: t.isPrimary, exists: await treeTableExists(tableName) }); } res.json({ tables: allTables, rebuildInProgress: treeRebuildInProgress }); } catch (err) { handleApiError(res, err); } }); app.post("/api/audit/run", async (_req, res) => { try { const result = await runIntegrityAudit(); res.json(result); } catch (err) { handleApiError(res, err, "Ошибка при запуске аудита"); } }); app.get("/api/audit/history", (_req, res) => { res.json(getAuditHistory()); }); scheduleTreeRebuild(); return httpServer; }