319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
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 });
|
||
}
|
||
});
|
||
}
|