273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
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<string>();
|
|
[...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<string, { node: IncomeCategoryFile; parentId: string | null }> {
|
|
const map = new Map<string, { node: IncomeCategoryFile; parentId: string | null }>();
|
|
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<string, { node: IncomeCategoryFile; parentId: string | null }>, 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<string, number>();
|
|
const chartSet = new Set((catFilter || []));
|
|
const monthlyMap = new Map<string, { incVat: number; incVatAmt: number; incNoVat: number; inc: number }>();
|
|
|
|
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<string>();
|
|
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<string, number>();
|
|
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 });
|
|
}
|
|
});
|
|
}
|