Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2
This commit is contained in:
318
server/finance/debt.ts
Normal file
318
server/finance/debt.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user