Финансы: дебиторка (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

566
server/finance/audit.ts Normal file
View File

@@ -0,0 +1,566 @@
import { type Express } from "express";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { query } from "./db-client";
import {
getTableConfig,
getIncomeCategories,
getExpenseCategories,
getIncomeAutoDetectConfig,
getExpenseAutoDetectConfig,
type IncomeCategoryFile,
type AutoDetectConfig,
} from "./table-config";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
// =====================
// Аудит целостности финансовых данных (перенесено из Data-Insight2 audit-agent.ts)
// =====================
interface CategoryRule {
column: string;
operator: string;
value: string;
}
interface Category {
id: string;
name: string;
rules: CategoryRule[];
ruleLogic?: "and" | "or";
children: Category[];
excluded?: boolean;
autoDetected?: boolean;
treeId?: string;
}
interface AuditResult {
timestamp: string;
income: DirectionAudit;
expense: DirectionAudit;
status: "ok" | "warning" | "error";
summary: string;
}
interface DirectionAudit {
dbTotal: number;
dbCount: number;
treeTotal: number;
treeCount: number;
excludedTotal: number;
excludedCount: number;
uncategorizedTotal: number;
uncategorizedCount: number;
gapTotal: number;
gapCount: number;
gapRows: { columns: Record<string, string>; sum: number; count: number }[];
treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[];
}
const HISTORY_PATH = resolve(process.cwd(), "data", "finance-audit-history.json");
const MAX_HISTORY = 50;
let auditHistory: AuditResult[] = [];
function loadHistory(): void {
if (!existsSync(HISTORY_PATH)) return;
try {
const parsed = JSON.parse(readFileSync(HISTORY_PATH, "utf-8"));
if (Array.isArray(parsed)) auditHistory = parsed;
} catch {
// повреждённый файл — начинаем с чистой истории
}
}
function saveHistory(): void {
try {
writeFileSync(HISTORY_PATH, JSON.stringify(auditHistory, null, 2), "utf-8");
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка сохранения истории:", err);
}
}
loadHistory();
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
for (const cat of cats) {
result.push(cat);
if (cat.children?.length) result.push(...flattenCategories(cat.children));
}
return result;
}
function matchTokens(haystack: string, needle: string): boolean {
const tokens = needle.split(/\s+/).filter(Boolean);
const lower = haystack.toLowerCase();
return tokens.every(t => lower.includes(t.toLowerCase()));
}
function rowMatchesRule(row: Record<string, any>, rule: CategoryRule): 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: CategoryRule[], logic: "and" | "or" = "and"): boolean {
if (!rules || rules.length === 0) return false;
if (logic === "or") return rules.some(r => rowMatchesRule(row, r));
return rules.every(r => rowMatchesRule(row, r));
}
function collectRuleColumns(cats: Category[]): Set<string> {
const cols = new Set<string>();
for (const cat of cats) {
for (const rule of (cat.rules || [])) cols.add(rule.column);
if (cat.children?.length) {
for (const col of collectRuleColumns(cat.children)) cols.add(col);
}
}
return cols;
}
function rowMatchesAutoPath(row: Record<string, any>, autoPath: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }): boolean {
for (let i = 0; i < autoPath.ruleSets.length - 1; i++) {
if (!rowMatchesRules(row, autoPath.ruleSets[i])) return false;
}
return rowMatchesRules(row, autoPath.ruleSets[autoPath.ruleSets.length - 1], autoPath.logic);
}
function computeNodeFromAgg(
cat: Category,
siblings: Category[],
ancestorRuleSets: CategoryRule[][],
ancestorIds: Set<string>,
allFlat: Category[],
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
aggRows: Record<string, any>[],
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
): { total: number; count: number; excluded: boolean; children: any[] } {
const catLogic = cat.ruleLogic || "and";
const idx = siblings.indexOf(cat);
const laterSibs = siblings.slice(idx + 1);
const allRuleSets = [...ancestorRuleSets, cat.rules];
let total = 0;
let count = 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 && exclPaths.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 && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) continue;
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue;
total += row.total;
count += row.cnt;
}
const children: any[] = [];
if (cat.children?.length) {
const childAncestorIds = new Set(ancestorIds);
childAncestorIds.add(cat.id);
for (const child of cat.children) {
children.push(computeNodeFromAgg(child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, aggRows, exclPaths));
}
}
return { total, count, excluded: !!cat.excluded, children };
}
function nodeSubtreeTotal(n: { total: number; excluded: boolean; children: 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: { count: number; excluded: boolean; children: 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;
}
function nodeExcludedTotal(n: { total: number; excluded: boolean; children: any[] }): number {
if (n.excluded) {
let t = n.total;
for (const ch of n.children) t += nodeExcludedTotal(ch);
return t;
}
let t = 0;
for (const ch of n.children) t += nodeExcludedTotal(ch);
return t;
}
function nodeExcludedCount(n: { count: number; excluded: boolean; children: any[] }): number {
if (n.excluded) {
let c = n.count;
for (const ch of n.children) c += nodeExcludedCount(ch);
return c;
}
let c = 0;
for (const ch of n.children) c += nodeExcludedCount(ch);
return c;
}
async function auditDirection(
table: string,
sumColumn: string,
cats: Category[],
autoConfig: AutoDetectConfig,
allowedColumns: string[]
): Promise<DirectionAudit> {
const allFlat = flattenCategories(cats);
const ruleColumns = collectRuleColumns(cats);
for (const tree of autoConfig.trees) {
for (const col of tree.columns) ruleColumns.add(col);
}
const groupCols = ruleColumns.size > 0
? [...ruleColumns].filter(c => allowedColumns.includes(c))
: allowedColumns.slice(0, 4);
const selectCols = groupCols.map(c => `"${c}"`).join(", ");
const aggResult = await query(
`SELECT ${selectCols}, SUM("${sumColumn}"::numeric) as total, COUNT(*) as cnt FROM "${table}" GROUP BY ${selectCols}`,
[]
);
const aggRows = aggResult.rows.map((r: any) => {
const row: Record<string, any> = { total: Number(r.total), cnt: Number(r.cnt) };
for (const col of groupCols) row[col] = r[col] ?? "";
return row;
});
let dbTotal = 0, dbCount = 0;
for (const row of aggRows) { dbTotal += row.total; dbCount += row.cnt; }
const primaryTreeIds = new Set(autoConfig.trees.filter(t => t.isPrimary).map(t => t.id));
const groupedByTree = new Map<string, Category[]>();
for (const cat of cats) {
const tid = cat.treeId || "__manual__";
if (!groupedByTree.has(tid)) groupedByTree.set(tid, []);
groupedByTree.get(tid)!.push(cat);
}
const autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
const excludedPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
function collectAllPaths(categories: Category[], ancestors: CategoryRule[][]) {
for (const cat of categories) {
const path = [...ancestors, cat.rules || []];
if (cat.excluded) {
excludedPaths.push({ id: cat.id, ruleSets: path, logic: (cat.ruleLogic || "and") });
} else if (cat.autoDetected && cat.rules?.length) {
autoPaths.push({ ruleSets: path, logic: (cat.ruleLogic || "and") });
}
if (cat.children?.length) {
collectAllPaths(cat.children, path);
}
}
}
collectAllPaths(cats, []);
const treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[] = [];
let allTreeTotal = 0, allTreeCount = 0;
let allExclTotal = 0, allExclCount = 0;
for (const [tid, treeCats] of groupedByTree) {
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
const treeName = tid === "__manual__"
? "Ручные категории"
: autoConfig.trees.find(t => t.id === tid)?.name || tid;
const nodes: any[] = [];
for (const cat of treeCats) {
nodes.push(computeNodeFromAgg(cat, treeCats, [], new Set(), allFlat, autoPaths, aggRows, excludedPaths));
}
const tTotal = nodes.reduce((s, n) => s + nodeSubtreeTotal(n), 0);
const tCount = nodes.reduce((s, n) => s + nodeSubtreeCount(n), 0);
const eTotal = nodes.reduce((s, n) => s + nodeExcludedTotal(n), 0);
const eCount = nodes.reduce((s, n) => s + nodeExcludedCount(n), 0);
treeBreakdown.push({ name: treeName, total: tTotal, count: tCount, isPrimary });
if (isPrimary) {
allTreeTotal += tTotal;
allTreeCount += tCount;
allExclTotal += eTotal;
allExclCount += eCount;
}
}
function isRowMatchedByTreeAudit(row: Record<string, any>, treeCats: Category[]): boolean {
for (const cat of treeCats) {
const catLogic = cat.ruleLogic || "and";
if (!cat.rules?.length || !rowMatchesRules(row, cat.rules, catLogic)) continue;
return true;
}
return false;
}
let uncatTotal = 0, uncatCount = 0;
const gapRows: { columns: Record<string, string>; sum: number; count: number }[] = [];
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 (isRowMatchedByTreeAudit(row, treeCats)) { matched = true; break; }
}
if (!matched) {
uncatTotal += row.total;
uncatCount += row.cnt;
}
}
const accountedTotal = allTreeTotal + allExclTotal + uncatTotal;
const accountedCount = allTreeCount + allExclCount + uncatCount;
const gapTotal = dbTotal - accountedTotal;
const gapCount = dbCount - accountedCount;
if (Math.abs(gapCount) > 0) {
for (const row of aggRows) {
let inTree = false;
let inUncat = true;
for (const [tid, treeCats] of groupedByTree) {
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
if (!isPrimary) continue;
if (isRowMatchedByTreeAudit(row, treeCats)) { inUncat = false; break; }
}
if (!inUncat) {
for (const [tid, treeCats] of groupedByTree) {
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
if (!isPrimary) continue;
for (const cat of treeCats) {
if (isRowCapturedByNode(row, cat, treeCats, [], new Set(), allFlat, autoPaths, excludedPaths)) {
inTree = true; break;
}
}
if (inTree) break;
}
}
if (!inUncat && !inTree) {
const cols: Record<string, string> = {};
for (const col of groupCols) cols[col] = String(row[col] || "");
gapRows.push({ columns: cols, sum: row.total, count: row.cnt });
}
}
}
return {
dbTotal, dbCount,
treeTotal: allTreeTotal, treeCount: allTreeCount,
excludedTotal: allExclTotal, excludedCount: allExclCount,
uncategorizedTotal: uncatTotal, uncategorizedCount: uncatCount,
gapTotal: Math.round(gapTotal * 100) / 100,
gapCount,
gapRows: gapRows.slice(0, 50),
treeBreakdown,
};
}
function isRowCapturedByNode(
row: Record<string, any>,
cat: Category,
siblings: Category[],
ancestorRuleSets: CategoryRule[][],
ancestorIds: Set<string>,
allFlat: Category[],
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
): boolean {
const catLogic = cat.ruleLogic || "and";
const idx = siblings.indexOf(cat);
const laterSibs = siblings.slice(idx + 1);
const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs));
if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) return false;
if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) return false;
if (!cat.autoDetected && exclPaths.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)))) return false;
if (!cat.autoDetected && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) return false;
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) {
const allRuleSets = [...ancestorRuleSets, cat.rules];
const childAncestorIds = new Set(ancestorIds);
childAncestorIds.add(cat.id);
for (const child of cat.children) {
if (isRowCapturedByNode(row, child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, exclPaths)) return true;
}
return false;
}
return true;
}
const INCOME_COLUMNS = [
"statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands",
"naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta",
"dogovorzakazchika", "statyadop", "schetnaoplatu", "nomervhodyaschegodokumenta",
];
const EXPENSE_COLUMNS = [
"statya", "dochka", "vnuchka", "zakazchik", "naznachenieplatezha",
"statyadop", "kommentarii", "otvetstvenniipokontraktu", "dogovorzakazchika",
"dogovorkontragenta",
];
let auditRunning = false;
export async function runIntegrityAudit(): Promise<AuditResult> {
if (auditRunning) throw new Error("Аудит уже выполняется");
auditRunning = true;
try {
console.log("[AUDIT-AGENT] Запуск аудита целостности данных...");
const config = getTableConfig();
const [income, expense] = await Promise.all([
auditDirection(
config.incomeTable,
config.incomeSumColumn,
getIncomeCategories() as Category[],
getIncomeAutoDetectConfig(),
INCOME_COLUMNS
),
auditDirection(
config.expenseTable,
config.expenseSumColumn,
getExpenseCategories() as Category[],
getExpenseAutoDetectConfig(),
EXPENSE_COLUMNS
),
]);
const hasGap = income.gapCount !== 0 || expense.gapCount !== 0;
const status = hasGap ? "warning" : "ok";
const summaryParts: string[] = [];
if (income.gapCount === 0 && expense.gapCount === 0) {
summaryParts.push("Все строки распределены корректно");
} else {
if (income.gapCount !== 0) summaryParts.push(`Доходы: ${income.gapCount} строк (${income.gapTotal.toLocaleString("ru")}₽) не распределены`);
if (expense.gapCount !== 0) summaryParts.push(`Расходы: ${expense.gapCount} строк (${expense.gapTotal.toLocaleString("ru")}₽) не распределены`);
}
const result: AuditResult = {
timestamp: new Date().toISOString(),
income,
expense,
status,
summary: summaryParts.join(". "),
};
auditHistory.unshift(result);
if (auditHistory.length > MAX_HISTORY) auditHistory.pop();
saveHistory();
console.log(`[AUDIT-AGENT] Аудит завершён: ${result.summary}`);
console.log(`[AUDIT-AGENT] Доходы: БД=${income.dbCount} строк, деревья=${income.treeCount}, excluded=${income.excludedCount}, нераспр=${income.uncategorizedCount}, gap=${income.gapCount}`);
console.log(`[AUDIT-AGENT] Расходы: БД=${expense.dbCount} строк, деревья=${expense.treeCount}, excluded=${expense.excludedCount}, нераспр=${expense.uncategorizedCount}, gap=${expense.gapCount}`);
return result;
} finally {
auditRunning = false;
}
}
export function getAuditHistory(): AuditResult[] {
return auditHistory;
}
// =====================
// Планировщик: ежедневно в 03:00 (МСК), без node-cron
// =====================
const TICK_MS = 60_000;
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; // МСК = UTC+3
const AUDIT_HOUR_MSK = 3;
let lastRunDay: string | null = auditHistory[0]?.timestamp
? new Date(new Date(auditHistory[0].timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10)
: null;
let schedulerStarted = false;
function mskToday(): string {
return new Date(Date.now() + MSK_OFFSET_MS).toISOString().slice(0, 10);
}
function mskHourNow(): number {
return new Date(Date.now() + MSK_OFFSET_MS).getUTCHours();
}
async function tick(): Promise<void> {
const today = mskToday();
if (lastRunDay === today) return; // сегодня уже запускался
if (mskHourNow() < AUDIT_HOUR_MSK) return; // ещё не наступило 03:00 по МСК
lastRunDay = today;
try {
await runIntegrityAudit();
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка при выполнении аудита:", err);
}
}
export function startAuditScheduler(): void {
if (schedulerStarted) return;
schedulerStarted = true;
setInterval(() => {
tick().catch((err) => console.error("[AUDIT-AGENT] Tick error:", err));
}, TICK_MS);
console.log("[AUDIT-AGENT] Планировщик запущен (ежедневно в 03:00 по МСК)");
// Первый запуск через 10 секунд после старта сервера
setTimeout(async () => {
try {
const result = await runIntegrityAudit();
lastRunDay = new Date(new Date(result.timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка при начальном аудите:", err);
}
}, 10000);
}
// =====================
// HTTP endpoints
// =====================
export function registerAuditRoutes(app: Express) {
app.get("/api/audit/history", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, history: getAuditHistory() });
});
app.post("/api/audit/run", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => {
try {
const result = await runIntegrityAudit();
res.json({ success: true, result });
} catch (err: any) {
console.error("[AUDIT-AGENT] Ошибка ручного запуска:", err);
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/audit/status", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, last: auditHistory[0] || null, running: auditRunning });
});
}

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 });
}
});
}

View File

@@ -0,0 +1,36 @@
// Матчинг строк финансовых таблиц по правилам категорий (деревья).
// Переиспользуется routes.ts (tree-summary, категории) и calculations.ts (налог на прибыль).
export function matchRule(row: Record<string, any>, rule: { column: string; operator: string; value: string }): boolean {
const val = row[rule.column];
const strVal = val == null ? "" : String(val);
const numVal = parseFloat(strVal);
const ruleNum = parseFloat(rule.value);
switch (rule.operator) {
case "equals": return strVal === rule.value;
case "not_equals": return strVal !== rule.value;
case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase());
case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum;
case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum;
case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum;
default: return false;
}
}
// Возвращает id глубочайшей совпавшей категории или null
export function applyCategoryRules(row: Record<string, any>, categories: any[]): string | null {
for (const cat of categories) {
const match = cat.ruleLogic === "or"
? cat.rules.some((r: any) => matchRule(row, r))
: cat.rules.every((r: any) => matchRule(row, r));
if (match) {
if (cat.children && cat.children.length > 0) {
const child = applyCategoryRules(row, cat.children);
if (child) return child;
}
return cat.id;
}
}
return null;
}

View File

@@ -75,6 +75,12 @@ export async function query<T extends QueryResultRow = any>(sql: string, params?
return p.query<T>(sql, params);
}
// Клиент для ручных транзакций (BEGIN/COMMIT/ROLLBACK) — см. google-sheets.ts (синхронизация зарплат)
export async function getPoolClient(): Promise<PoolClient> {
const p = getPool();
return p.connect();
}
export async function testConnection(config: DbConfig): Promise<{ ok: boolean; message: string }> {
const testPool = createPool(config);
try {

318
server/finance/debt.ts Normal file
View File

@@ -0,0 +1,318 @@
import { type Express } from "express";
import { query } from "./db-client";
import * as cache from "./cache";
import { getTableConfig } from "./table-config";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
// =====================
// Дебиторская задолженность (перенесено из Data-Insight2)
// =====================
function getDebtTable(): string {
return getTableConfig().debtTable || "vw_debt_active";
}
async function getMaxPeriod(): Promise<string> {
const cacheKey = "debt:__max_period__";
const cached = cache.get<string>(cacheKey);
if (cached) return cached;
const res = await query(`SELECT MAX(period) as mp FROM ${getDebtTable()}`);
const mp = res.rows[0]?.mp ? new Date(res.rows[0].mp).toISOString() : new Date().toISOString();
cache.set(cacheKey, mp);
return mp;
}
function parseDate(d: unknown): string | null {
if (!d) return null;
return new Date(d as string).toISOString();
}
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";
}
function debtCacheKey(prefix: string, queryParams: Record<string, unknown>): string {
return `${prefix}:${JSON.stringify(queryParams)}`;
}
export function registerDebtRoutes(app: Express) {
app.get("/api/debt/analytics", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const cacheKey = debtCacheKey("debt:analytics", req.query as Record<string, unknown>);
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
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)!);
}
const result = {
success: true,
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),
},
};
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
console.error("[debt/analytics]", err);
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/debt/dynamics", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const cacheKey = debtCacheKey("debt:dynamics", req.query as Record<string, unknown>);
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
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);
}
const result = { success: true, rows };
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
console.error("[debt/dynamics]", err);
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/debt/dynamics/kontragenty", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const cacheKey = debtCacheKey("debt:dyn-kontr", req.query as Record<string, unknown>);
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
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);
const result = {
success: true,
rows: 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),
})),
};
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
console.error("[debt/dynamics/kontragenty]", err);
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/debt/filters", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => {
try {
const cacheKey = "debt:filters";
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
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]),
]);
const result = {
success: true,
filters: {
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),
},
};
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
console.error("[debt/filters]", err);
res.status(500).json({ success: false, error: err.message });
}
});
}

View File

@@ -0,0 +1,494 @@
import { type Express } from "express";
import { google } from "googleapis";
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { query, getPoolClient } from "./db-client";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
// =====================
// Google OAuth + участки + синхронизация зарплат (перенесено из Data-Insight2)
// =====================
const TOKENS_PATH = resolve(process.cwd(), "data", "google-tokens.json");
const SCOPES = [
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/userinfo.email",
];
function getRedirectUri(): string {
return process.env.FINANCE_GOOGLE_REDIRECT_URI || "https://iistwin.ru/api/finance/google/callback";
}
function createOAuth2Client() {
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
getRedirectUri()
);
}
interface StoredTokens {
access_token: string;
refresh_token?: string;
expiry_date?: number;
token_type?: string;
scope?: string;
email?: string;
}
function loadTokens(): StoredTokens | null {
try {
if (existsSync(TOKENS_PATH)) {
return JSON.parse(readFileSync(TOKENS_PATH, "utf-8"));
}
} catch {}
return null;
}
function saveTokens(tokens: StoredTokens): void {
const dir = dirname(TOKENS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2), "utf-8");
}
function clearTokens(): void {
try {
if (existsSync(TOKENS_PATH)) unlinkSync(TOKENS_PATH);
} catch {}
}
export function getAuthUrl(): string {
const client = createOAuth2Client();
return client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
prompt: "consent",
});
}
async function handleCallback(code: string): Promise<{ email?: string }> {
const client = createOAuth2Client();
const { tokens } = await client.getToken(code);
client.setCredentials(tokens);
let email: string | undefined;
try {
const oauth2 = google.oauth2({ version: "v2", auth: client });
const userInfo = await oauth2.userinfo.get();
email = userInfo.data.email || undefined;
} catch {}
saveTokens({
access_token: tokens.access_token!,
refresh_token: tokens.refresh_token || undefined,
expiry_date: tokens.expiry_date || undefined,
token_type: tokens.token_type || undefined,
scope: tokens.scope || undefined,
email,
});
return { email };
}
export function getAuthStatus(): { loggedIn: boolean; email?: string } {
const tokens = loadTokens();
if (!tokens?.access_token) return { loggedIn: false };
return { loggedIn: true, email: tokens.email };
}
export async function getAuthenticatedClient() {
const tokens = loadTokens();
if (!tokens?.access_token) throw new Error("Не авторизован в Google");
const client = createOAuth2Client();
client.setCredentials({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expiry_date: tokens.expiry_date,
});
const needsRefresh = tokens.expiry_date
? tokens.expiry_date < Date.now() + 60000
: false;
if (needsRefresh && tokens.refresh_token) {
try {
const { credentials } = await client.refreshAccessToken();
client.setCredentials(credentials);
saveTokens({
...tokens,
access_token: credentials.access_token!,
expiry_date: credentials.expiry_date || undefined,
});
} catch (err) {
clearTokens();
throw new Error("Токен Google истёк. Пожалуйста, войдите заново.");
}
}
client.on("tokens", (newTokens: any) => {
const current = loadTokens();
if (current && newTokens.access_token) {
saveTokens({
...current,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date || current.expiry_date,
refresh_token: newTokens.refresh_token || current.refresh_token,
});
}
});
return client;
}
async function getSheetNames(spreadsheetId: string): Promise<string[]> {
const auth = await getAuthenticatedClient();
const sheets = google.sheets({ version: "v4", auth });
const res = await sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties.title" });
return (res.data.sheets || []).map((s: any) => s.properties?.title || "").filter(Boolean);
}
async function getSheetData(spreadsheetId: string, range: string): Promise<any[][]> {
const auth = await getAuthenticatedClient();
const sheets = google.sheets({ version: "v4", auth });
const res = await sheets.spreadsheets.values.get({ spreadsheetId, range });
return res.data.values || [];
}
// =====================
// Участки (data/salary-areas.json)
// =====================
const SALARY_AREAS_PATH = resolve(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 (existsSync(SALARY_AREAS_PATH)) {
const raw = JSON.parse(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 {
writeFileSync(SALARY_AREAS_PATH, JSON.stringify(areas, null, 2), "utf-8");
}
function extractSpreadsheetId(url: string): string | null {
const m = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
return m ? m[1] : null;
}
const COL_PATTERN = /^[A-Za-z]{1,2}$/;
// =====================
// Загрузка данных зарплат из 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: err?.message || "Ошибка загрузки" });
}
await sleep(600);
}
return results;
}
// Таблица opneof создаётся в salary-routes.ts (ensureTables)
async function ensureOpneof(): Promise<boolean> {
try {
const r = await query(`SELECT to_regclass('opneof') as reg`);
return !!r.rows[0]?.reg;
} catch {
return false;
}
}
let syncInProgress = false;
// =====================
// Routes
// =====================
export function registerGoogleSheetsRoutes(app: Express) {
// --- Google OAuth ---
app.get("/api/finance/google/status", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, ...getAuthStatus() });
});
app.get("/api/finance/google/auth-url", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, url: getAuthUrl() });
});
app.get("/api/finance/google/callback", async (req, res) => {
try {
const code = req.query.code as string;
if (!code) { res.redirect("/salary?google_auth=error"); return; }
await handleCallback(code);
res.redirect("/salary?google_auth=success");
} catch (err) {
console.error("[google callback]", err);
res.redirect("/salary?google_auth=error");
}
});
app.post("/api/finance/google/logout", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
clearTokens();
res.json({ success: true });
});
// --- Участки ---
app.get("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => {
res.json({ success: true, areas: loadAreas() });
});
app.post("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
try {
const { name, spreadsheetUrl, fioCol = "B", bazaCol = "F", kVydacheCol = "J" } = req.body || {};
if (!name || !spreadsheetUrl) return res.status(400).json({ success: false, error: "Укажите название участка и ссылку на таблицу" });
const spreadsheetId = extractSpreadsheetId(spreadsheetUrl);
if (!spreadsheetId) return res.status(400).json({ success: false, error: "Неверная ссылка на Google Таблицу" });
if (!COL_PATTERN.test(fioCol) || !COL_PATTERN.test(bazaCol) || !COL_PATTERN.test(kVydacheCol)) {
return res.status(400).json({ success: false, error: "Столбцы должны быть буквами (A-Z)" });
}
const areas = loadAreas();
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const area = { id, name, spreadsheetId, fioCol: fioCol.toUpperCase(), bazaCol: bazaCol.toUpperCase(), kVydacheCol: kVydacheCol.toUpperCase() };
areas.push(area);
saveAreas(areas);
res.json({ success: true, area });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, error: "Ошибка при добавлении участка" });
}
});
app.patch("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
try {
const areas = loadAreas();
const area = areas.find(a => a.id === req.params.id);
if (!area) return res.status(404).json({ success: false, error: "Участок не найден" });
const { fioCol, bazaCol, kVydacheCol } = req.body || {};
if (fioCol !== undefined) {
if (!COL_PATTERN.test(fioCol)) return res.status(400).json({ success: false, error: "Столбец ФИО должен быть буквой (A-Z)" });
area.fioCol = fioCol.toUpperCase();
}
if (bazaCol !== undefined) {
if (!COL_PATTERN.test(bazaCol)) return res.status(400).json({ success: false, error: "Столбец База должен быть буквой (A-Z)" });
area.bazaCol = bazaCol.toUpperCase();
}
if (kVydacheCol !== undefined) {
if (!COL_PATTERN.test(kVydacheCol)) return res.status(400).json({ success: false, error: "Столбец К выдаче должен быть буквой (A-Z)" });
area.kVydacheCol = kVydacheCol.toUpperCase();
}
saveAreas(areas);
res.json({ success: true, area });
} catch (err) {
console.error(err);
res.status(500).json({ success: false, error: "Ошибка при обновлении участка" });
}
});
app.delete("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, 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({ success: false, error: "Ошибка при удалении участка" });
}
});
// --- Синхронизация зарплат ---
app.post("/api/salary/sync", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => {
try {
if (syncInProgress) return res.status(409).json({ success: false, error: "Синхронизация уже выполняется" });
syncInProgress = true;
const status = getAuthStatus();
if (!status.loggedIn) { syncInProgress = false; return res.status(401).json({ success: false, error: "Не авторизован в Google", authRequired: true }); }
if (!(await ensureOpneof())) { syncInProgress = false; return res.status(503).json({ success: false, error: "Таблица opneof не готова" }); }
const areas = loadAreas();
if (areas.length === 0) { syncInProgress = false; return res.json({ success: true, 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({ success: false, error: "Ошибка авторизации Google: " + firstErr, authRequired: true });
}
return res.status(500).json({ success: false, 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({ success: true, 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({ success: false, error: err.message, authRequired: true });
}
console.error("[salary sync] Error:", err);
res.status(500).json({ success: false, error: "Ошибка синхронизации: " + (err?.message || "неизвестная ошибка") });
}
});
}

View File

@@ -23,6 +23,11 @@ import { tenantIsolation } from "../middleware/tenant.middleware";
import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync";
import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync";
import { getFinanceSettings, setFinanceSettings } from "./settings";
import { applyCategoryRules } from "./category-matching";
import { registerDebtRoutes } from "./debt";
import { registerCalculationRoutes } from "./calculations";
import { registerAuditRoutes, startAuditScheduler } from "./audit";
import { registerGoogleSheetsRoutes } from "./google-sheets";
// Rebuild status (in-memory)
let rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: null as string | null };
@@ -59,39 +64,6 @@ function buildWhere(
return { clause, params, paramIndex: idx };
}
function applyCategoryRules(row: Record<string, any>, categories: any[]): string | null {
for (const cat of categories) {
const match = cat.ruleLogic === "or"
? cat.rules.some((r: any) => matchRule(row, r))
: cat.rules.every((r: any) => matchRule(row, r));
if (match) {
if (cat.children && cat.children.length > 0) {
const child = applyCategoryRules(row, cat.children);
if (child) return child;
}
return cat.id;
}
}
return null;
}
function matchRule(row: Record<string, any>, rule: { column: string; operator: string; value: string }): boolean {
const val = row[rule.column];
const strVal = val == null ? "" : String(val);
const numVal = parseFloat(strVal);
const ruleNum = parseFloat(rule.value);
switch (rule.operator) {
case "equals": return strVal === rule.value;
case "not_equals": return strVal !== rule.value;
case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase());
case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum;
case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum;
case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum;
default: return false;
}
}
// =====================
// Connection & Config
// =====================
@@ -730,4 +702,9 @@ export async function registerFinanceRoutes(app: Express): Promise<void> {
registerAutoDetectRoutes(app);
registerTimeseriesRoutes(app);
await registerSalaryRoutes(app);
registerDebtRoutes(app);
registerCalculationRoutes(app);
registerAuditRoutes(app);
registerGoogleSheetsRoutes(app);
startAuditScheduler();
}

View File

@@ -6,6 +6,7 @@ export interface TableConfig {
expenseTable: string;
incomeSumColumn: string;
expenseSumColumn: string;
debtTable?: string;
columnLabels: Record<string, string>;
}
@@ -58,6 +59,7 @@ export function getTableConfig(): TableConfig {
expenseTable: "expenses",
incomeSumColumn: "summa",
expenseSumColumn: "summadokumenta",
debtTable: "vw_debt_active",
columnLabels: {
data: "Дата",
summa: "Сумма",