Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2
This commit is contained in:
272
server/finance/calculations.ts
Normal file
272
server/finance/calculations.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user