Files
iistwin/server/finance/debt.ts

319 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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