- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
207 lines
9.6 KiB
TypeScript
207 lines
9.6 KiB
TypeScript
import { type Express } from "express";
|
|
import { query } from "./db-client";
|
|
import {
|
|
getIncomeAutoDetectConfig,
|
|
setIncomeAutoDetectConfig,
|
|
getExpenseAutoDetectConfig,
|
|
setExpenseAutoDetectConfig,
|
|
getIncomeCategories,
|
|
setIncomeCategories,
|
|
getExpenseCategories,
|
|
setExpenseCategories,
|
|
getTableConfig,
|
|
} from "./table-config";
|
|
import * as cache from "./cache";
|
|
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
|
|
|
function getConfig(type: string) {
|
|
return type === "expense" ? getExpenseAutoDetectConfig() : getIncomeAutoDetectConfig();
|
|
}
|
|
function setConfig(type: string, config: any) {
|
|
return type === "expense" ? setExpenseAutoDetectConfig(config) : setIncomeAutoDetectConfig(config);
|
|
}
|
|
function getCategories(type: string) {
|
|
return type === "expense" ? getExpenseCategories() : getIncomeCategories();
|
|
}
|
|
function setCategories(type: string, cats: any[]) {
|
|
return type === "expense" ? setExpenseCategories(cats) : setIncomeCategories(cats);
|
|
}
|
|
|
|
function buildWhere(filters: any[], filterLogic: string): string {
|
|
if (!filters || filters.length === 0) return "";
|
|
const logic = filterLogic === "or" ? " OR " : " AND ";
|
|
const clauses = filters.map((f: any) => `"${f.column}" ILIKE '%${f.value.replace(/'/g, "''")}%'"`);
|
|
return "WHERE " + clauses.join(logic);
|
|
}
|
|
|
|
export function registerAutoDetectRoutes(app: Express): void {
|
|
// GET /api/:type-categories/auto-detect/config
|
|
app.get("/api/income-categories/auto-detect/config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, config: getIncomeAutoDetectConfig() });
|
|
});
|
|
app.get("/api/expense-categories/auto-detect/config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
res.json({ success: true, config: getExpenseAutoDetectConfig() });
|
|
});
|
|
|
|
// POST /api/:type-categories/auto-detect/config/trees
|
|
app.post("/api/income-categories/auto-detect/config/trees", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getIncomeAutoDetectConfig();
|
|
const tree = { ...req.body, id: crypto.randomUUID() };
|
|
config.trees.push(tree);
|
|
setIncomeAutoDetectConfig(config);
|
|
res.status(201).json({ success: true, tree });
|
|
});
|
|
app.post("/api/expense-categories/auto-detect/config/trees", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getExpenseAutoDetectConfig();
|
|
const tree = { ...req.body, id: crypto.randomUUID() };
|
|
config.trees.push(tree);
|
|
setExpenseAutoDetectConfig(config);
|
|
res.status(201).json({ success: true, tree });
|
|
});
|
|
|
|
// PUT /api/:type-categories/auto-detect/config/trees/:treeId
|
|
app.put("/api/income-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getIncomeAutoDetectConfig();
|
|
const idx = config.trees.findIndex((t) => t.id === req.params.treeId);
|
|
if (idx >= 0) { config.trees[idx] = { ...config.trees[idx], ...req.body }; setIncomeAutoDetectConfig(config); }
|
|
res.json({ success: true });
|
|
});
|
|
app.put("/api/expense-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getExpenseAutoDetectConfig();
|
|
const idx = config.trees.findIndex((t) => t.id === req.params.treeId);
|
|
if (idx >= 0) { config.trees[idx] = { ...config.trees[idx], ...req.body }; setExpenseAutoDetectConfig(config); }
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// DELETE /api/:type-categories/auto-detect/config/trees/:treeId
|
|
app.delete("/api/income-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getIncomeAutoDetectConfig();
|
|
config.trees = config.trees.filter((t) => t.id !== req.params.treeId);
|
|
setIncomeAutoDetectConfig(config);
|
|
res.json({ success: true });
|
|
});
|
|
app.delete("/api/expense-categories/auto-detect/config/trees/:treeId", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
const config = getExpenseAutoDetectConfig();
|
|
config.trees = config.trees.filter((t) => t.id !== req.params.treeId);
|
|
setExpenseAutoDetectConfig(config);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// GET /api/:type-categories/auto-detect/preview?treeId=...
|
|
app.get("/api/income-categories/auto-detect/preview", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await previewAutoDetect(req, res, "income");
|
|
});
|
|
app.get("/api/expense-categories/auto-detect/preview", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await previewAutoDetect(req, res, "expense");
|
|
});
|
|
|
|
// POST /api/:type-categories/auto-detect?treeId=...
|
|
app.post("/api/income-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await runAutoDetect(req, res, "income");
|
|
});
|
|
app.post("/api/expense-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await runAutoDetect(req, res, "expense");
|
|
});
|
|
|
|
// POST /api/:type-categories/auto-detect/sync?treeId=...
|
|
app.post("/api/income-categories/auto-detect/sync", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await runAutoDetect(req, res, "income", true);
|
|
});
|
|
app.post("/api/expense-categories/auto-detect/sync", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
|
|
await runAutoDetect(req, res, "expense", true);
|
|
});
|
|
|
|
// DELETE /api/:type-categories/auto-detect?treeId=...
|
|
app.delete("/api/income-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
deleteAutoDetect(req, res, "income");
|
|
});
|
|
app.delete("/api/expense-categories/auto-detect", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
|
|
deleteAutoDetect(req, res, "expense");
|
|
});
|
|
}
|
|
|
|
async function previewAutoDetect(req: AuthenticatedRequest, res: any, type: string) {
|
|
try {
|
|
const treeId = req.query.treeId as string;
|
|
const config = getConfig(type);
|
|
const tree = config.trees.find((t: any) => t.id === treeId);
|
|
if (!tree) { res.status(404).json({ success: false, error: "Дерево не найдено" }); return; }
|
|
|
|
const tconf = getTableConfig();
|
|
const table = type === "expense" ? tconf.expenseTable : tconf.incomeTable;
|
|
const colResult = await query(`SELECT DISTINCT "${tree.columns[0]}" FROM "${table}" WHERE "${tree.columns[0]}" IS NOT NULL LIMIT 20`);
|
|
const categories = colResult.rows.map((r: any, i: number) => ({
|
|
id: crypto.randomUUID(),
|
|
name: r[tree.columns[0]],
|
|
rules: [{ column: tree.columns[0], operator: "equals" as const, value: r[tree.columns[0]] }],
|
|
ruleLogic: "and",
|
|
children: [],
|
|
}));
|
|
res.json({ success: true, categories });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
}
|
|
|
|
async function runAutoDetect(req: AuthenticatedRequest, res: any, type: string, sync: boolean = false) {
|
|
try {
|
|
const treeId = req.query.treeId as string;
|
|
const config = getConfig(type);
|
|
const tree = config.trees.find((t: any) => t.id === treeId);
|
|
if (!tree) { res.status(404).json({ success: false, error: "Дерево не найдено" }); return; }
|
|
|
|
const tconf = getTableConfig();
|
|
const table = type === "expense" ? tconf.expenseTable : tconf.incomeTable;
|
|
const { clause, params } = buildWhereQuery(tree.filters, tree.filterLogic);
|
|
const colResult = await query(`SELECT DISTINCT "${tree.columns[0]}" FROM "${table}" ${clause} AND "${tree.columns[0]}" IS NOT NULL`, params);
|
|
|
|
const currentCats = getCategories(type);
|
|
const newCats = colResult.rows.map((r: any) => ({
|
|
id: crypto.randomUUID(),
|
|
name: r[tree.columns[0]],
|
|
rules: [{ column: tree.columns[0], operator: "equals", value: r[tree.columns[0]] }],
|
|
ruleLogic: "and",
|
|
excluded: false,
|
|
autoDetected: true,
|
|
treeId: tree.id,
|
|
children: [],
|
|
}));
|
|
|
|
if (sync) {
|
|
// Remove old auto-detected categories for this tree
|
|
const cleaned = currentCats.filter((c: any) => c.treeId !== tree.id);
|
|
setCategories(type, [...cleaned, ...newCats]);
|
|
} else {
|
|
setCategories(type, [...currentCats, ...newCats]);
|
|
}
|
|
|
|
cache.invalidatePrefix(`${type}:`);
|
|
res.json({ success: true, created: newCats.length });
|
|
} catch (err: any) {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
}
|
|
}
|
|
|
|
function deleteAutoDetect(req: AuthenticatedRequest, res: any, type: string) {
|
|
const treeId = req.query.treeId as string;
|
|
const currentCats = getCategories(type);
|
|
const cleaned = currentCats.filter((c: any) => c.treeId !== treeId);
|
|
setCategories(type, cleaned);
|
|
cache.invalidatePrefix(`${type}:`);
|
|
res.json({ success: true });
|
|
}
|
|
|
|
function buildWhereQuery(filters: any[], filterLogic: string): { clause: string; params: any[] } {
|
|
if (!filters || filters.length === 0) return { clause: "", params: [] };
|
|
const logic = filterLogic === "or" ? " OR " : " AND ";
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
for (const f of filters) {
|
|
conditions.push(`"${f.column}" ILIKE $${idx}`);
|
|
params.push(`%${f.value}%`);
|
|
idx++;
|
|
}
|
|
return { clause: `WHERE ${conditions.join(logic)}`, params };
|
|
}
|