import { type Express } from "express"; import { query } from "./db-client"; import * as cache from "./cache"; import { getTableConfig, getIncomeCategories, getExpenseCategories, type IncomeCategoryFile, } from "./table-config"; import { applyCategoryRules } from "./category-matching"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; // ===================== // Расчёты: НДС и налог на прибыль (перенесено из Data-Insight2) // ===================== 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 r2 = (v: number) => Math.round(v * 100) / 100; function buildDateConds(params: unknown[], dateFrom?: string, dateTo?: string): string { const conds: string[] = ["1=1"]; if (dateFrom) { params.push(dateFrom); conds.push(`data >= $${params.length}`); } if (dateTo) { params.push(dateTo); conds.push(`data <= $${params.length}`); } return `WHERE ${conds.join(" AND ")}`; } export function registerCalculationRoutes(app: Express) { app.get("/api/calculations/vat", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const { dateFrom, dateTo } = req.query; const config = getTableConfig(); const cacheKey = `calc:vat:${dateFrom}:${dateTo}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } const incParams: unknown[] = []; const expParams: unknown[] = []; const incWhere = buildDateConds(incParams, dateFrom as string, dateTo as string); const expWhere = buildDateConds(expParams, dateFrom as string, dateTo as string); const [incByRate, expByRate, incMonthly, expMonthly] = await Promise.all([ query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${incWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, incParams), query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${expWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, expParams), query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${incWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, incParams), query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${expWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, expParams), ]); const incomeByRate = incByRate.rows.map((r: any) => ({ rate: r.rate, count: Number(r.cnt), total: Number(r.total), vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), })); const expenseByRate = expByRate.rows.map((r: any) => ({ rate: r.rate, count: Number(r.cnt), total: Number(r.total), vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), })); const totalIncomeVat = incomeByRate.reduce((s: number, r: any) => s + r.vat, 0); const totalExpenseVat = expenseByRate.reduce((s: number, r: any) => s + r.vat, 0); const periodsSet = new Set(); [...incMonthly.rows, ...expMonthly.rows].forEach((r: any) => periodsSet.add(r.period)); const periods = [...periodsSet].sort(); const monthly = periods.map(p => { let incVat = 0; let expVat = 0; incMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { const rate = parseRate(r.rate); incVat += Number(r.total) * rate / (1 + rate); }); expMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { const rate = parseRate(r.rate); expVat += Number(r.total) * rate / (1 + rate); }); return { period: p, incomeVat: r2(incVat), expenseVat: r2(expVat), toPay: r2(incVat - expVat) }; }); const result = { success: true, incomeByRate, expenseByRate, totalIncomeVat: r2(totalIncomeVat), totalExpenseVat: r2(totalExpenseVat), toPay: r2(totalIncomeVat - totalExpenseVat), monthly, }; cache.set(cacheKey, result); res.json(result); } catch (err: any) { console.error("[calculations/vat]", err); res.status(500).json({ success: false, error: err.message }); } }); app.post("/api/calculations/profit-tax", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { try { const { dateFrom, dateTo, taxRate: taxRateParam } = req.query; const { incomeCategories, expenseCategories, chartCategories } = req.body || {}; const config = getTableConfig(); const cacheKey = `calc:profit-tax:${dateFrom}:${dateTo}:${taxRateParam}:${JSON.stringify(req.body || {})}`; const cached = cache.get(cacheKey); if (cached) { res.json(cached); return; } // Мапа id узла → { node, parentId } по всем деревьям категорий (для excluded-предков) function buildNodeMap(cats: IncomeCategoryFile[]): Map { const map = new Map(); const walk = (nodes: IncomeCategoryFile[], parentId: string | null) => { for (const n of nodes) { map.set(n.id, { node: n, parentId }); if (n.children?.length) walk(n.children, n.id); } }; walk(cats, null); return map; } function isExcluded(map: Map, id: string): boolean { let cur = map.get(id); while (cur) { if (cur.node.excluded) return true; cur = cur.parentId ? map.get(cur.parentId) : undefined; } return false; } // Выбираем строки за период из базовых таблиц, категорию определяем в JS через applyCategoryRules async function loadDirection( table: string, sumColumn: string, categories: IncomeCategoryFile[], catFilter?: string[], ) { const params: unknown[] = []; const where = buildDateConds(params, dateFrom as string, dateTo as string); const rowsResult = await query( `SELECT data, "${sumColumn}" as amount, COALESCE(stavkands, 'Без НДС') as rate, TO_CHAR(data, 'YYYY-MM') as period FROM "${table}" ${where}`, params ); const nodeMap = buildNodeMap(categories); const exclMap = new Map(); const chartSet = new Set((catFilter || [])); const monthlyMap = new Map(); let withVat = 0, vatAmount = 0, noVat = 0, total = 0; for (const row of rowsResult.rows as any[]) { const catId = applyCategoryRules(row, categories); if (!catId) continue; // без категории — в расчёте не участвуют (в источнике их нет в derevo-таблицах) const node = nodeMap.get(catId); if (!node) continue; if (isExcluded(nodeMap, catId)) { exclMap.set(node.node.name, (exclMap.get(node.node.name) || 0) + (parseFloat(row.amount) || 0)); continue; } if (chartSet.size > 0 && !chartSet.has(catId)) continue; const amount = parseFloat(row.amount) || 0; total += amount; const period = row.period; const m = monthlyMap.get(period) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; m.inc += amount; if (isNoVatRate(row.rate)) { noVat += amount; m.incNoVat += amount; } else { const rate = parseRate(row.rate); const vat = amount * rate / (1 + rate); withVat += amount; vatAmount += vat; m.incVat += amount; m.incVatAmt += vat; } monthlyMap.set(period, m); } const excluded = Array.from(exclMap.entries()) .filter(([, t]) => t > 0) .map(([name, t]) => ({ name, total: r2(t) })); return { withVat, vatAmount, noVat, total, monthlyMap, excluded }; } 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 [inc, exp] = await Promise.all([ loadDirection(config.incomeTable, config.incomeSumColumn, getIncomeCategories(), incCatFilter), loadDirection(config.expenseTable, config.expenseSumColumn, getExpenseCategories(), expCatFilter), ]); const incomeWithVatBase = inc.withVat - inc.vatAmount; const expenseWithVatBase = exp.withVat - exp.vatAmount; const profit = (incomeWithVatBase - expenseWithVatBase) + (inc.noVat - exp.noVat); const taxRate = taxRateParam ? Number(taxRateParam) / 100 : 0.20; const tax = profit > 0 ? profit * taxRate : 0; const periodsSet = new Set(); for (const p of inc.monthlyMap.keys()) periodsSet.add(p); for (const p of exp.monthlyMap.keys()) periodsSet.add(p); const periods = [...periodsSet].sort(); const monthly = periods.map(p => { const mi = inc.monthlyMap.get(p) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; const me = exp.monthlyMap.get(p) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; const prof = ((mi.incVat - mi.incVatAmt) - (me.incVat - me.incVatAmt)) + (mi.incNoVat - me.incNoVat); return { period: p, income: r2(mi.inc), expenses: r2(me.inc), profit: r2(prof), tax: r2(prof > 0 ? prof * taxRate : 0) }; }); // Помесячная агрегация по категориям графиков 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 isIncome = cc.type === "income"; const table = isIncome ? config.incomeTable : config.expenseTable; const sumColumn = isIncome ? config.incomeSumColumn : config.expenseSumColumn; const categories = isIncome ? getIncomeCategories() : getExpenseCategories(); const nodeMap = buildNodeMap(categories); const catNode = nodeMap.get(cc.id); const params: unknown[] = []; const where = buildDateConds(params, dateFrom as string, dateTo as string); const rowsResult = await query( `SELECT "${sumColumn}" as amount, TO_CHAR(data, 'YYYY-MM') as period FROM "${table}" ${where}`, params ); const periodMap = new Map(); for (const row of rowsResult.rows as any[]) { const catId = applyCategoryRules(row, categories); if (catId !== cc.id) continue; if (catNode && isExcluded(nodeMap, cc.id)) continue; const period = row.period; periodMap.set(period, (periodMap.get(period) || 0) + (parseFloat(row.amount) || 0)); } return { id: cc.id, name: cc.name || (catNode?.node.name ?? cc.id), type: cc.type, data: [...periodMap.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([period, amount]) => ({ period, amount: r2(amount) })), }; }); categoryMonthly = await Promise.all(chartCatPromises); } const result = { success: true, income: r2(inc.total), expenses: r2(exp.total), totalIncome: r2(inc.total), totalExpenses: r2(exp.total), incomeWithVat: r2(inc.withVat), incomeVatAmount: r2(inc.vatAmount), incomeWithVatBase: r2(incomeWithVatBase), incomeNoVat: r2(inc.noVat), expenseWithVat: r2(exp.withVat), expenseVatAmount: r2(exp.vatAmount), expenseWithVatBase: r2(expenseWithVatBase), expenseNoVat: r2(exp.noVat), profit: r2(profit), taxRate, tax: r2(tax), monthly, excludedIncomeCategories: inc.excluded, excludedExpenseCategories: exp.excluded, categoryMonthly, }; cache.set(cacheKey, result); res.json(result); } catch (err: any) { console.error("[calculations/profit-tax]", err); res.status(500).json({ success: false, error: err.message }); } }); }