709 lines
29 KiB
TypeScript
709 lines
29 KiB
TypeScript
import { type Express, type Request, type Response } from "express";
|
|
import { query, testConnection, setConfig, getConfig, isConnected } from "./db-client";
|
|
import { registerSalaryRoutes } from "./salary-routes";
|
|
import { ensureFinanceTables, ensureViews } from "./views";
|
|
import { registerOverrideRoutes } from "./overrides";
|
|
import { registerTagRoutes } from "./tags";
|
|
import { registerAutoDetectRoutes } from "./auto-detect";
|
|
import { registerTimeseriesRoutes } from "./timeseries";
|
|
import * as cache from "./cache";
|
|
import {
|
|
getTableConfig,
|
|
getIncomeCategories,
|
|
setIncomeCategories,
|
|
getExpenseCategories,
|
|
setExpenseCategories,
|
|
getIncomeAutoDetectConfig,
|
|
setIncomeAutoDetectConfig,
|
|
getExpenseAutoDetectConfig,
|
|
setExpenseAutoDetectConfig,
|
|
} from "./table-config";
|
|
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
|
import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync";
|
|
import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync";
|
|
import { getFinanceSettings, setFinanceSettings } from "./settings";
|
|
import { applyCategoryRules, buildGroupedTrees } from "./category-matching";
|
|
import { registerDebtRoutes } from "./debt";
|
|
import { registerCalculationRoutes } from "./calculations";
|
|
import { registerAuditRoutes, startAuditScheduler } from "./audit";
|
|
import { registerGoogleSheetsRoutes } from "./google-sheets";
|
|
|
|
// Rebuild status (in-memory)
|
|
let rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: null as string | null };
|
|
|
|
function buildWhere(
|
|
filters: Record<string, string>,
|
|
dateFrom?: string,
|
|
dateTo?: string,
|
|
dateColumn = "data"
|
|
): { clause: string; params: any[]; paramIndex: number } {
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
for (const [col, val] of Object.entries(filters)) {
|
|
if (!val) continue;
|
|
conditions.push(`"${col}" ILIKE $${idx}`);
|
|
params.push(`%${val}%`);
|
|
idx++;
|
|
}
|
|
|
|
if (dateFrom) {
|
|
conditions.push(`"${dateColumn}" >= $${idx}`);
|
|
params.push(dateFrom);
|
|
idx++;
|
|
}
|
|
if (dateTo) {
|
|
conditions.push(`"${dateColumn}" <= $${idx}`);
|
|
params.push(dateTo);
|
|
idx++;
|
|
}
|
|
|
|
const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
return { clause, params, paramIndex: idx };
|
|
}
|
|
|
|
// =====================
|
|
// Connection & Config
|
|
// =====================
|
|
|
|
function registerConnectionRoutes(app: Express) {
|
|
app.get("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, config: getConfig() });
|
|
});
|
|
|
|
app.post("/api/connection/test", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
const result = await testConnection(req.body);
|
|
res.json(result);
|
|
});
|
|
|
|
app.post("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
setConfig(req.body);
|
|
cache.invalidate();
|
|
res.json({ success: true, message: "Конфигурация сохранена" });
|
|
});
|
|
|
|
app.get("/api/db-status", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
const connected = await isConnected().catch(() => false);
|
|
res.json({ connected });
|
|
});
|
|
|
|
app.get("/api/db/tables", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const result = await query(
|
|
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`
|
|
);
|
|
res.json({ success: true, tables: result.rows.map((r) => r.table_name) });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/db/columns/:table", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const result = await query(
|
|
`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`,
|
|
[req.params.table]
|
|
);
|
|
res.json({ success: true, columns: result.rows });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/table-config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, config: getTableConfig() });
|
|
});
|
|
|
|
app.get("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, settings: getFinanceSettings() });
|
|
});
|
|
|
|
app.post("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
setFinanceSettings(req.body);
|
|
res.json({ success: true });
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// Income
|
|
// =====================
|
|
|
|
function registerIncomeRoutes(app: Express) {
|
|
app.get("/api/income", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
|
|
const sortBy = (req.query.sortBy as string) || "id";
|
|
const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC";
|
|
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
|
|
const cacheKey = `income:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached) { res.json(cached); return; }
|
|
|
|
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
|
|
const countResult = await query(`SELECT COUNT(*) FROM "${config.incomeTable}" ${clause}`, params);
|
|
const total = parseInt(countResult.rows[0].count, 10);
|
|
|
|
const sumResult = await query(`SELECT COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${clause}`, params);
|
|
const totalSum = parseFloat(sumResult.rows[0].total);
|
|
|
|
const offset = (page - 1) * pageSize;
|
|
const rowsResult = await query(
|
|
`SELECT * FROM "${config.incomeTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
|
[...params, pageSize, offset]
|
|
);
|
|
|
|
const result = {
|
|
success: true,
|
|
rows: rowsResult.rows,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
totalSum,
|
|
};
|
|
cache.set(cacheKey, result);
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/income/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const categories = getIncomeCategories();
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
|
|
const cacheKey = `income:tree:${dateFrom}:${dateTo}`;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached) { res.json(cached); return; }
|
|
|
|
const { clause, params } = buildWhere({}, dateFrom, dateTo);
|
|
const result = await query(
|
|
`SELECT * FROM "${config.incomeTable}" ${clause}`,
|
|
params
|
|
);
|
|
|
|
const rows = result.rows;
|
|
const treeTotals = new Map<string, { total: number; count: number; minDate?: string; maxDate?: string }>();
|
|
|
|
for (const row of rows) {
|
|
const sum = parseFloat(row[config.incomeSumColumn]) || 0;
|
|
const catId = applyCategoryRules(row, categories);
|
|
if (catId) {
|
|
const existing = treeTotals.get(catId) || { total: 0, count: 0 };
|
|
existing.total += sum;
|
|
existing.count++;
|
|
const date = row.data;
|
|
if (date) {
|
|
if (!existing.minDate || date < existing.minDate) existing.minDate = date;
|
|
if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date;
|
|
}
|
|
treeTotals.set(catId, existing);
|
|
}
|
|
}
|
|
|
|
const uncategorized = rows.reduce((acc, row) => {
|
|
const catId = applyCategoryRules(row, categories);
|
|
if (!catId) {
|
|
acc.total += parseFloat(row[config.incomeSumColumn]) || 0;
|
|
acc.count++;
|
|
}
|
|
return acc;
|
|
}, { total: 0, count: 0 });
|
|
|
|
const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0);
|
|
|
|
function buildTreeNodes(nodes: any[]): any[] {
|
|
return nodes.map((node) => {
|
|
const stats = treeTotals.get(node.id) || { total: 0, count: 0 };
|
|
return {
|
|
id: node.id,
|
|
name: node.name,
|
|
total: stats.total,
|
|
count: stats.count,
|
|
excluded: node.excluded,
|
|
autoDetected: node.autoDetected,
|
|
minDate: stats.minDate,
|
|
maxDate: stats.maxDate,
|
|
children: node.children ? buildTreeNodes(node.children) : undefined,
|
|
};
|
|
}).filter((n) => n.total > 0 || (n.children && n.children.length > 0));
|
|
}
|
|
|
|
const trees = buildGroupedTrees(
|
|
categories,
|
|
getIncomeAutoDetectConfig(),
|
|
treeTotals,
|
|
(roots) => buildTreeNodes(roots)
|
|
);
|
|
|
|
const response = {
|
|
success: true,
|
|
trees,
|
|
uncategorized,
|
|
grandTotal: { total: grandTotal, count: rows.length },
|
|
};
|
|
cache.set(cacheKey, response);
|
|
res.json(response);
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/income/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const column = req.query.column as string;
|
|
const search = (req.query.search as string) || "";
|
|
const context = req.query.context ? JSON.parse(req.query.context as string) : {};
|
|
|
|
if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; }
|
|
|
|
const { clause, params } = buildWhere(context);
|
|
const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : "";
|
|
if (search) params.push(`%${search}%`);
|
|
|
|
const result = await query(
|
|
`SELECT DISTINCT "${column}" FROM "${config.incomeTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`,
|
|
params
|
|
);
|
|
res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/income/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const categoryId = req.params.categoryId;
|
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
|
|
|
|
const categories = getIncomeCategories();
|
|
function findCategory(nodes: any[], id: string): any | null {
|
|
for (const node of nodes) {
|
|
if (node.id === id) return node;
|
|
if (node.children) {
|
|
const found = findCategory(node.children, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
const category = findCategory(categories, categoryId);
|
|
|
|
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
|
|
const result = await query(`SELECT * FROM "${config.incomeTable}" ${clause}`, params);
|
|
const allRows = result.rows;
|
|
|
|
const matchedRows = category
|
|
? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId)
|
|
: allRows.filter((row) => !applyCategoryRules(row, categories));
|
|
|
|
const total = matchedRows.length;
|
|
const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0);
|
|
const offset = (page - 1) * pageSize;
|
|
const rows = matchedRows.slice(offset, offset + pageSize);
|
|
|
|
res.json({
|
|
success: true,
|
|
rows,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
totalSum,
|
|
});
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// Expenses (mirror of income)
|
|
// =====================
|
|
|
|
function registerExpenseRoutes(app: Express) {
|
|
app.get("/api/expenses", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
|
|
const sortBy = (req.query.sortBy as string) || "id";
|
|
const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC";
|
|
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
|
|
const cacheKey = `expenses:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached) { res.json(cached); return; }
|
|
|
|
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
|
|
const countResult = await query(`SELECT COUNT(*) FROM "${config.expenseTable}" ${clause}`, params);
|
|
const total = parseInt(countResult.rows[0].count, 10);
|
|
|
|
const sumResult = await query(`SELECT COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${clause}`, params);
|
|
const totalSum = parseFloat(sumResult.rows[0].total);
|
|
|
|
const offset = (page - 1) * pageSize;
|
|
const rowsResult = await query(
|
|
`SELECT * FROM "${config.expenseTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
|
[...params, pageSize, offset]
|
|
);
|
|
|
|
const result = {
|
|
success: true,
|
|
rows: rowsResult.rows,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
totalSum,
|
|
};
|
|
cache.set(cacheKey, result);
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/expenses/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const categories = getExpenseCategories();
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
|
|
const cacheKey = `expenses:tree:${dateFrom}:${dateTo}`;
|
|
const cached = cache.get(cacheKey);
|
|
if (cached) { res.json(cached); return; }
|
|
|
|
const { clause, params } = buildWhere({}, dateFrom, dateTo);
|
|
const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params);
|
|
const rows = result.rows;
|
|
|
|
const treeTotals = new Map<string, { total: number; count: number; minDate?: string; maxDate?: string }>();
|
|
for (const row of rows) {
|
|
const sum = parseFloat(row[config.expenseSumColumn]) || 0;
|
|
const catId = applyCategoryRules(row, categories);
|
|
if (catId) {
|
|
const existing = treeTotals.get(catId) || { total: 0, count: 0 };
|
|
existing.total += sum;
|
|
existing.count++;
|
|
const date = row.data;
|
|
if (date) {
|
|
if (!existing.minDate || date < existing.minDate) existing.minDate = date;
|
|
if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date;
|
|
}
|
|
treeTotals.set(catId, existing);
|
|
}
|
|
}
|
|
|
|
const uncategorized = rows.reduce((acc, row) => {
|
|
const catId = applyCategoryRules(row, categories);
|
|
if (!catId) { acc.total += parseFloat(row[config.expenseSumColumn]) || 0; acc.count++; }
|
|
return acc;
|
|
}, { total: 0, count: 0 });
|
|
|
|
const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0);
|
|
|
|
function buildTreeNodes(nodes: any[]): any[] {
|
|
return nodes.map((node) => {
|
|
const stats = treeTotals.get(node.id) || { total: 0, count: 0 };
|
|
return {
|
|
id: node.id, name: node.name, total: stats.total, count: stats.count,
|
|
excluded: node.excluded, autoDetected: node.autoDetected,
|
|
minDate: stats.minDate, maxDate: stats.maxDate,
|
|
children: node.children ? buildTreeNodes(node.children) : undefined,
|
|
};
|
|
}).filter((n) => n.total > 0 || (n.children && n.children.length > 0));
|
|
}
|
|
|
|
const trees = buildGroupedTrees(
|
|
categories,
|
|
getExpenseAutoDetectConfig(),
|
|
treeTotals,
|
|
(roots) => buildTreeNodes(roots)
|
|
);
|
|
|
|
const response = { success: true, trees, uncategorized, grandTotal: { total: grandTotal, count: rows.length } };
|
|
cache.set(cacheKey, response);
|
|
res.json(response);
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/expenses/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const column = req.query.column as string;
|
|
const search = (req.query.search as string) || "";
|
|
const context = req.query.context ? JSON.parse(req.query.context as string) : {};
|
|
if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; }
|
|
const { clause, params } = buildWhere(context);
|
|
const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : "";
|
|
if (search) params.push(`%${search}%`);
|
|
const result = await query(
|
|
`SELECT DISTINCT "${column}" FROM "${config.expenseTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`,
|
|
params
|
|
);
|
|
res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/expenses/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const config = getTableConfig();
|
|
const categoryId = req.params.categoryId;
|
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
|
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
|
|
const dateFrom = req.query.dateFrom as string;
|
|
const dateTo = req.query.dateTo as string;
|
|
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
|
|
|
|
const categories = getExpenseCategories();
|
|
function findCategory(nodes: any[], id: string): any | null {
|
|
for (const node of nodes) { if (node.id === id) return node; if (node.children) { const f = findCategory(node.children, id); if (f) return f; } }
|
|
return null;
|
|
}
|
|
const category = findCategory(categories, categoryId);
|
|
|
|
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
|
|
const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params);
|
|
const allRows = result.rows;
|
|
const matchedRows = category
|
|
? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId)
|
|
: allRows.filter((row) => !applyCategoryRules(row, categories));
|
|
const total = matchedRows.length;
|
|
const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0);
|
|
const offset = (page - 1) * pageSize;
|
|
const rows = matchedRows.slice(offset, offset + pageSize);
|
|
res.json({ success: true, rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// Categories
|
|
// =====================
|
|
|
|
function registerCategoryRoutes(app: Express) {
|
|
app.get("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, categories: getIncomeCategories() });
|
|
});
|
|
|
|
app.post("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getIncomeCategories();
|
|
const newCat = { ...req.body, id: crypto.randomUUID(), children: [] };
|
|
current.push(newCat);
|
|
setIncomeCategories(current);
|
|
cache.invalidatePrefix("income:");
|
|
res.status(201).json({ success: true, category: newCat });
|
|
});
|
|
|
|
app.put("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getIncomeCategories();
|
|
function update(nodes: any[]): boolean {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; }
|
|
if (nodes[i].children && update(nodes[i].children)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
update(current);
|
|
setIncomeCategories(current);
|
|
cache.invalidatePrefix("income:");
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.patch("/api/income-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getIncomeCategories();
|
|
function toggle(nodes: any[]): boolean {
|
|
for (const node of nodes) {
|
|
if (node.id === req.params.id) { node.excluded = !node.excluded; return true; }
|
|
if (node.children && toggle(node.children)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
toggle(current);
|
|
setIncomeCategories(current);
|
|
cache.invalidatePrefix("income:");
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getIncomeCategories();
|
|
function remove(nodes: any[]): any[] {
|
|
return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] }));
|
|
}
|
|
setIncomeCategories(remove(current));
|
|
cache.invalidatePrefix("income:");
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Expense categories (mirror)
|
|
app.get("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, categories: getExpenseCategories() });
|
|
});
|
|
|
|
app.post("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getExpenseCategories();
|
|
const newCat = { ...req.body, id: crypto.randomUUID(), children: [] };
|
|
current.push(newCat);
|
|
setExpenseCategories(current);
|
|
cache.invalidatePrefix("expenses:");
|
|
res.status(201).json({ success: true, category: newCat });
|
|
});
|
|
|
|
app.put("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getExpenseCategories();
|
|
function update(nodes: any[]): boolean {
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; }
|
|
if (nodes[i].children && update(nodes[i].children)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
update(current);
|
|
setExpenseCategories(current);
|
|
cache.invalidatePrefix("expenses:");
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.patch("/api/expense-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getExpenseCategories();
|
|
function toggle(nodes: any[]): boolean {
|
|
for (const node of nodes) {
|
|
if (node.id === req.params.id) { node.excluded = !node.excluded; return true; }
|
|
if (node.children && toggle(node.children)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
toggle(current);
|
|
setExpenseCategories(current);
|
|
cache.invalidatePrefix("expenses:");
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const current = getExpenseCategories();
|
|
function remove(nodes: any[]): any[] {
|
|
return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] }));
|
|
}
|
|
setExpenseCategories(remove(current));
|
|
cache.invalidatePrefix("expenses:");
|
|
res.json({ success: true });
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// Cache & Admin
|
|
// =====================
|
|
|
|
function registerAdminRoutes(app: Express) {
|
|
app.post("/api/cache/invalidate", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
cache.invalidate(req.body.pattern);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post("/api/refresh-all", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
cache.invalidate();
|
|
rebuildStatus = { inProgress: true, pendingRebuild: true, lastRebuiltAt: null };
|
|
setTimeout(() => {
|
|
rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: new Date().toISOString() };
|
|
}, 2000);
|
|
res.json({ success: true, message: "Синхронизация запущена" });
|
|
});
|
|
|
|
app.get("/api/admin/rebuild-status", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, ...rebuildStatus });
|
|
});
|
|
|
|
app.post("/api/finance/customer-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const formId = parseInt(req.body.formId) || 8;
|
|
const result = await previewCustomerSync(formId, req.organizationId!);
|
|
res.json({ success: true, ...result });
|
|
} catch (err: any) {
|
|
console.error("Customer sync preview error:", err);
|
|
res.status(500).json({ success: false, error: err.message || "Ошибка при анализе заказчиков" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/finance/customer-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const formId = parseInt(req.body.formId) || 8;
|
|
const customers = (req.body.customers || []) as SyncApplyCustomer[];
|
|
const result = await applyCustomerSync(formId, req.organizationId!, req.user!.id, customers);
|
|
res.json({ success: true, ...result });
|
|
} catch (err: any) {
|
|
console.error("Customer sync apply error:", err);
|
|
res.status(500).json({ success: false, error: err.message || "Ошибка при создании заказчиков" });
|
|
}
|
|
});
|
|
|
|
// Contract sync endpoints
|
|
app.post("/api/finance/contract-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const formId = parseInt(req.body.formId) || 9;
|
|
const result = await previewContractSync(formId, req.organizationId!);
|
|
res.json({ success: true, ...result });
|
|
} catch (err: any) {
|
|
console.error("Contract sync preview error:", err);
|
|
res.status(500).json({ success: false, error: err.message || "Ошибка при анализе договоров" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/finance/contract-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const formId = parseInt(req.body.formId) || 9;
|
|
const contracts = (req.body.contracts || []) as SyncApplyContract[];
|
|
const result = await applyContractSync(formId, req.organizationId!, req.user!.id, contracts);
|
|
res.json({ success: true, ...result });
|
|
} catch (err: any) {
|
|
console.error("Contract sync apply error:", err);
|
|
res.status(500).json({ success: false, error: err.message || "Ошибка при создании договоров" });
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function registerFinanceRoutes(app: Express): Promise<void> {
|
|
await ensureFinanceTables();
|
|
await ensureViews();
|
|
registerConnectionRoutes(app);
|
|
registerIncomeRoutes(app);
|
|
registerExpenseRoutes(app);
|
|
registerCategoryRoutes(app);
|
|
registerAdminRoutes(app);
|
|
registerOverrideRoutes(app);
|
|
registerTagRoutes(app);
|
|
registerAutoDetectRoutes(app);
|
|
registerTimeseriesRoutes(app);
|
|
await registerSalaryRoutes(app);
|
|
registerDebtRoutes(app);
|
|
registerCalculationRoutes(app);
|
|
registerAuditRoutes(app);
|
|
registerGoogleSheetsRoutes(app);
|
|
startAuditScheduler();
|
|
}
|