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

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