fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
206
server/finance/auto-detect.ts
Normal file
206
server/finance/auto-detect.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user