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

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

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 };
}