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

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

71 lines
2.8 KiB
TypeScript

import { type Express } from "express";
import { query } from "./db-client";
import { getTableConfig } from "./table-config";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import * as cache from "./cache";
export function registerTimeseriesRoutes(app: Express): void {
app.get("/api/income/tree-timeseries", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
await handleTreeTimeseries(req, res, "income");
});
app.get("/api/expenses/tree-timeseries", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
await handleTreeTimeseries(req, res, "expense");
});
}
async function handleTreeTimeseries(req: AuthenticatedRequest, res: any, type: "income" | "expense") {
try {
const config = getTableConfig();
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const groupBy = (req.query.groupBy as string) || "month";
const categoryId = req.query.categoryId as string | undefined;
const cacheKey = `${type}:timeseries:${dateFrom}:${dateTo}:${groupBy}:${categoryId}`;
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
const table = type === "income" ? config.incomeTable : config.expenseTable;
const sumCol = type === "income" ? config.incomeSumColumn : config.expenseSumColumn;
const dateFilter = [];
const params: any[] = [];
if (dateFrom) { dateFilter.push(`"data" >= $${params.length + 1}`); params.push(dateFrom); }
if (dateTo) { dateFilter.push(`"data" <= $${params.length + 1}`); params.push(dateTo); }
const where = dateFilter.length > 0 ? `WHERE ${dateFilter.join(" AND ")}` : "";
const dateTrunc = groupBy === "year" ? "year" : groupBy === "quarter" ? "quarter" : "month";
const result = await query(
`SELECT DATE_TRUNC('${dateTrunc}', "data"::date) as period, SUM("${sumCol}") as total, COUNT(*) as count
FROM "${table}" ${where}
GROUP BY period
ORDER BY period`,
params
);
const periods = result.rows.map((r: any) => {
const d = new Date(r.period);
if (groupBy === "year") return `${d.getFullYear()}`;
if (groupBy === "quarter") return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
});
const data = result.rows.map((r: any) => parseFloat(r.total) || 0);
const response = {
success: true,
periods,
series: [{
id: "total",
name: type === "income" ? "Доходы" : "Расходы",
data,
}],
};
cache.set(cacheKey, response);
res.json(response);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
}