Files
iistwin/server/finance/routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

734 lines
30 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";
// 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 };
}
function applyCategoryRules(row: Record<string, any>, categories: any[]): string | null {
for (const cat of categories) {
const match = cat.ruleLogic === "or"
? cat.rules.some((r: any) => matchRule(row, r))
: cat.rules.every((r: any) => matchRule(row, r));
if (match) {
if (cat.children && cat.children.length > 0) {
const child = applyCategoryRules(row, cat.children);
if (child) return child;
}
return cat.id;
}
}
return null;
}
function matchRule(row: Record<string, any>, rule: { column: string; operator: string; value: string }): boolean {
const val = row[rule.column];
const strVal = val == null ? "" : String(val);
const numVal = parseFloat(strVal);
const ruleNum = parseFloat(rule.value);
switch (rule.operator) {
case "equals": return strVal === rule.value;
case "not_equals": return strVal !== rule.value;
case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase());
case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum;
case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum;
case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum;
default: return false;
}
}
// =====================
// 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 = categories.map((tree) => ({
id: tree.id,
name: tree.name,
isPrimary: tree.treeId === "primary",
total: (treeTotals.get(tree.id) || { total: 0 }).total,
count: (treeTotals.get(tree.id) || { count: 0 }).count,
nodes: buildTreeNodes(tree.children || []),
}));
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 = categories.map((tree) => ({
id: tree.id, name: tree.name, isPrimary: tree.treeId === "primary",
total: (treeTotals.get(tree.id) || { total: 0 }).total,
count: (treeTotals.get(tree.id) || { count: 0 }).count,
nodes: buildTreeNodes(tree.children || []),
}));
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);
}