Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2

This commit is contained in:
2026-09-05 15:07:36 +03:00
parent d2c993b613
commit 7a8db03163
17 changed files with 4809 additions and 34 deletions

View File

@@ -0,0 +1,272 @@
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 });
}
});
}