Files
iistwin/server/finance-di2/routes.ts
Ильяс Султанов de9bbfe17a НДС в DI2: вкладка «Расчёты → НДС» переведена на декларационную витрину 1С
- Новый server/finance-di2/nds.routes.ts: GET /api/di2/nds/summary|quarters|input-balance|sync-status|contract-markers
  (только отображение nds_monthly/nds_input_balance/nds_sync_log/nds_contract_marker, без расчёта НДС)
- VatTab переписан: KPI, динамика (месяц с накопительным итогом квартала / квартал), по ставкам,
  stacked по типам операций, доля авансов, резерв вычетов (19 счёт), баннер свежести,
  таблица «деньги есть — отгрузки нет» (RED_NO_SHIPMENT > 1 млн)
- Старый GET /api/calculations/vat (вычисление 20/120 из платежей) удалён
2026-09-29 07:49:23 +03:00

6272 lines
277 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> {
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<string, string> = {
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<string, any>, 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<string, any>, 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<string, string> = {
а:"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<string, any>,
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<string, any>[],
treeCats: IncomeCategory[],
isPrimary: boolean,
excludeRowIds?: Set<string>
): Promise<Set<string>> {
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<string>();
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<string, any>[]): Promise<void> {
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<void> {
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<string, IncomeCategory[]>();
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<string>(["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<string, any> = { 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<string>(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<string, IncomeCategory[]>();
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<string>(["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<string, any> = { 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<string>(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<void> {
return new Promise<void>(resolve => {
treeRebuildWaiters.push(resolve);
if (treeRebuildInProgress) {
treeRebuildPending = true;
} else {
setImmediate(() => rebuildAllTreeTables().catch(console.error));
}
});
}
async function ensureTreesReady(): Promise<void> {
if (treeRebuildLastCompletedAt === null) {
await scheduleAndAwaitRebuild();
}
}
async function treeTableExists(tableName: string): Promise<boolean> {
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<IncomeCategory[]> {
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<string, NestedMap>;
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>): 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>): 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<string>, 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<string>, allowedColumns: string[] = ALLOWED_INCOME_COLUMNS(), onlyPrimaryTreeIds?: Set<string>, manualCatRuleSets?: IncomeCategory["rules"][]): string[] {
const skipSet = skipCategoryIds instanceof Set ? skipCategoryIds : (skipCategoryIds ? new Set([skipCategoryIds]) : new Set<string>());
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<string>, 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<void> {
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<Server> {
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<string, any> {
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<string, unknown>);
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<string, string>;
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<string> | 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<string, string>;
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<string>();
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<void> {
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<string, unknown>);
const result = await cachedQuery(cacheKey, TTL_SHORT, async () => {
const { dateFrom, dateTo } = req.query;
const incomeCats = loadCategories();
const ruleColumns = new Set<string>();
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<string, any>[] = aggResult.rows.map(r => {
const row: Record<string, any> = { 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<string, any>, 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<string, any>, 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<string, any>, 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<string, any>, 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<string> = 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<string, IncomeCategory[]>();
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<string, IncomeCategory["rules"][]>();
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<string, any>, 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<string, unknown>);
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<string>();
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<string, any>[] = aggResult.rows.map(r => {
const row: Record<string, any> = { 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<string, any>, 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<string, any>, 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<string, any>, 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<string>();
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<string> = 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<string> = 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<string, IncomeCategory[]>();
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<string, IncomeCategory["rules"][]>();
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<string>();
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<IncomeCategory[]> {
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<string, NestedMap>;
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<string, unknown>);
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<string, number>();
expenseData.rows.forEach((r: any) => expMap.set(r.zakazchik, Number(r.total)));
const incSet = new Set<string>();
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<string, { income: number; expense: number }>();
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<string, number>();
expenseContracts.rows.forEach((r: any) => expMap.set(r.dogovorzakazchika, Number(r.total)));
const contractSet = new Set<string>();
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<string, string>;
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<number> {
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<number> {
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<void> {
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<string, unknown>);
const result = await cachedQuery(cacheKey, TTL_SHORT, async () => {
const { dateFrom, dateTo } = req.query;
const expenseCats = loadExpenseCategories();
const ruleColumns = new Set<string>();
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<string, any>[] = aggResult.rows.map(r => {
const row: Record<string, any> = { 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<string, any>, 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<string, any>, 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<string, any>, 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<string> = 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<string, IncomeCategory[]>();
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<string, IncomeCategory["rules"][]>();
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<string, any>, 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<string, unknown>);
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<string>();
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<string, any>[] = aggResult.rows.map(r => {
const row: Record<string, any> = { month: r.month, total: Number(r.total) };
for (const col of groupCols) row[col] = r[col] ?? "";
return row;
});
function rowMatchesRule(row: Record<string, any>, 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<string, any>, 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<string, any>, 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<string, any>, 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<string, 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 monthMap = new Map<string, number>();
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<string>(aggRows.map(r => r.month));
const months = Array.from(monthsSet).sort();
const groupedByTree = new Map<string, IncomeCategory[]>();
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<string, number>; 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<string, number> = {};
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<string, number> = {};
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<string, unknown>);
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<string>();
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<string, any>[] = aggResult.rows.map(r => {
const row: Record<string, any> = { 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<string, any>, 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<string, any>, 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<string, any>, 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<string>();
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<string> = 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<string> = 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<string, IncomeCategory[]>();
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<string, IncomeCategory["rules"][]>();
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<string> | 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<string, string>;
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<string, unknown>);
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<string, any>();
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<string, any>();
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<string, { kontragent: string; totalDebt: number; totalOverdue: number; rows: any[] }>();
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<string, unknown>);
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<string, unknown>);
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<string>();
[...(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<string>();
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<string>) {
const byKey = new Map<string, Map<string, number>>();
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<string, number> }[] = [];
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<string, number>();
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<string, { income: number; expenses: number }>();
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<string, { periods: string[]; amounts: number[]; total: number }>; names: Map<string, string> }> {
const byCat = new Map<string, { periods: string[]; amounts: number[]; total: number }>();
const names = new Map<string, string>();
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<string> {
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(/<think>[\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<boolean> {
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<boolean> {
return tryInitOpneof();
}
// ── opof_manual_areas: persistent manual FIO → area overrides ──────────────
let opofManualAreasReady = false;
async function ensureOpofManualAreas(): Promise<boolean> {
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<boolean> {
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<any> {
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<string, { id: string; name: string; monthsMap: Map<string, { fio: string; baza: number; kVydache: number }[]> }>();
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 4, baseDelayMs = 1000): Promise<T> {
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<any[]> {
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<string, { id: string; name: string; canonicalFio: string }>();
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<string, {
id: string;
name: string;
monthsMap: Map<string, { fio: string; fioLetters: string; nachislenobezndfl: number; summandfl: number; strahov: number }[]>;
}>();
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<string>();
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<string, string> = {
"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<string, number>();
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<string, number>();
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;
}