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 { const cacheKey = "debt:__max_period__"; const cached = cache.get(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 { 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); 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(); 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(); 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(); 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); 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); 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 }); } }); }