Files
iistwin/server/finance/salary-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

347 lines
12 KiB
TypeScript

import { type Express, type Request, type Response } from "express";
import { query } from "./db-client";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
// =====================
// Levenshtein distance
// =====================
function levenshtein(a: string, b: string): number {
const m = a.length, n = b.length;
if (!m) return n;
if (!n) return m;
const prev = new Array(n + 1);
const curr = new Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
curr[j] =
a[i - 1] === b[j - 1]
? prev[j - 1]
: 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
}
for (let j = 0; j <= n; j++) prev[j] = curr[j];
}
return prev[n];
}
function similarity(a: string, b: string): number {
const dist = levenshtein(a, b);
return 1 - dist / Math.max(a.length, b.length);
}
function fioLetters(fio: string): string {
return fio.replace(/[^\u0400-\u04FF]/gi, "").toLowerCase();
}
// =====================
// Ensure tables exist
// =====================
const ENSURE_SQL = `
CREATE TABLE IF NOT EXISTS opneof (
id SERIAL PRIMARY KEY,
area_id TEXT NOT NULL,
area_name TEXT NOT NULL,
month TEXT NOT NULL,
fio TEXT NOT NULL,
baza NUMERIC NOT NULL DEFAULT 0,
k_vydache NUMERIC NOT NULL DEFAULT 0,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS opneof_ukey ON opneof (area_id, month, fio) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS opof_manual_areas (
fio_letters TEXT PRIMARY KEY,
fio_display TEXT NOT NULL,
area_id TEXT NOT NULL,
area_name TEXT NOT NULL,
canonical_fio TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`;
async function ensureTables() {
try {
await query(ENSURE_SQL);
} catch (err) {
console.error("[SALARY] Failed to ensure tables:", err);
}
}
// =====================
// DB Status
// =====================
function registerDbStatusRoutes(app: Express) {
app.get("/api/salary/db-status", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const totalResult = await query(`SELECT COUNT(*)::int AS total FROM opneof WHERE deleted_at IS NULL`);
const areasResult = await query(`SELECT DISTINCT area_name FROM opneof WHERE deleted_at IS NULL ORDER BY area_name`);
const monthResult = await query(`SELECT COUNT(DISTINCT month)::int AS month_count FROM opneof WHERE deleted_at IS NULL`);
const lastSyncResult = await query(`SELECT MAX(synced_at) AS last_sync FROM opneof WHERE deleted_at IS NULL`);
res.json({
success: true,
totalRows: totalResult.rows[0]?.total ?? 0,
areas: areasResult.rows.map((r: any) => r.area_name),
monthCount: monthResult.rows[0]?.month_count ?? 0,
lastSync: lastSyncResult.rows[0]?.last_sync ?? null,
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
// =====================
// неОФ
// =====================
function registerNeofRoutes(app: Express) {
app.get("/api/salary/neof", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(`
SELECT area_id, area_name, month, fio, baza, k_vydache
FROM opneof
WHERE deleted_at IS NULL
ORDER BY area_name, month, fio
`);
const areaMap = new Map<string, { id: string; name: string; months: Map<string, { month: string; employees: any[] }> }>();
for (const row of result.rows) {
if (!areaMap.has(row.area_id)) {
areaMap.set(row.area_id, { id: row.area_id, name: row.area_name, months: new Map() });
}
const area = areaMap.get(row.area_id)!;
if (!area.months.has(row.month)) {
area.months.set(row.month, { month: row.month, employees: [] });
}
area.months.get(row.month)!.employees.push({
fio: row.fio,
baza: parseFloat(row.baza) || 0,
kVydache: parseFloat(row.k_vydache) || 0,
});
}
const areas = Array.from(areaMap.values()).map((a) => ({
id: a.id,
name: a.name,
months: Array.from(a.months.values()).sort((a, b) => periodCompare(b.month, a.month)),
}));
res.json({ success: true, areas, source: "db" });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
function periodCompare(a: string, b: string): number {
const pa = parsePeriod(a);
const pb = parsePeriod(b);
return (pb.y * 12 + pb.m) - (pa.y * 12 + pa.m);
}
function parsePeriod(p: string): { m: number; y: number } {
const parts = p.split(".");
const m = parseInt(parts[0], 10) || 0;
let y = parseInt(parts[1], 10) || 0;
if (y < 50) y += 2000;
else if (y < 100) y += 1900;
return { m, y };
}
// =====================
// ОФ
// =====================
function registerZofRoutes(app: Express) {
app.get("/api/salary/zof/areas", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(`SELECT DISTINCT area_id, area_name FROM opneof WHERE deleted_at IS NULL ORDER BY area_name`);
res.json({ success: true, areas: result.rows.map((r: any) => ({ id: r.area_id, name: r.area_name })) });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/salary/zof", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
// Load manual assignments
const manualResult = await query(`SELECT fio_letters, area_id, area_name, canonical_fio FROM opof_manual_areas`);
const manualMap = new Map<string, { areaId: string; areaName: string; canonicalFio: string }>();
for (const row of manualResult.rows) {
manualMap.set(row.fio_letters, { areaId: row.area_id, areaName: row.area_name, canonicalFio: row.canonical_fio });
}
// Load canonical FIOs from neof for fuzzy matching
const neofFioResult = await query(`SELECT DISTINCT fio FROM opneof WHERE deleted_at IS NULL`);
const neofFios = neofFioResult.rows.map((r: any) => r.fio as string);
const neofLetters = neofFios.map((f) => ({ fio: f, letters: fioLetters(f) }));
// Load opof data
const opofResult = await query(`
SELECT sotrudnik, nachislenobezndfl, summandfl, strahov, periodregistrator
FROM opof
ORDER BY periodregistrator DESC, sotrudnik
`);
const areaMap = new Map<string, { id: string; name: string; months: Map<string, { month: string; employees: any[] }> }>();
function ensureArea(id: string, name: string) {
if (!areaMap.has(id)) areaMap.set(id, { id, name, months: new Map() });
return areaMap.get(id)!;
}
for (const row of opofResult.rows) {
const fio = row.sotrudnik as string;
const letters = fioLetters(fio);
const month = normalizePeriod(row.periodregistrator);
let areaId = "unassigned";
let areaName = "Нераспределённые";
let canonicalFio = fio;
// 1. Manual override
const manual = manualMap.get(letters);
if (manual) {
areaId = manual.areaId;
areaName = manual.areaName;
canonicalFio = manual.canonicalFio || fio;
} else {
// 2. Fuzzy match
let bestSim = 0;
let bestFio = "";
for (const nf of neofLetters) {
const sim = similarity(letters, nf.letters);
if (sim > bestSim) {
bestSim = sim;
bestFio = nf.fio;
}
}
if (bestSim >= 0.9 && bestFio) {
// find area for this fio
const areaRes = await query(
`SELECT area_id, area_name FROM opneof WHERE fio = $1 AND deleted_at IS NULL LIMIT 1`,
[bestFio]
);
if (areaRes.rows.length > 0) {
areaId = areaRes.rows[0].area_id;
areaName = areaRes.rows[0].area_name;
canonicalFio = bestFio;
}
}
}
const area = ensureArea(areaId, areaName);
if (!area.months.has(month)) {
area.months.set(month, { month, employees: [] });
}
area.months.get(month)!.employees.push({
fio,
fioLetters: letters,
nachislenobezndfl: parseFloat(row.nachislenobezndfl) || 0,
summandfl: parseFloat(row.summandfl) || 0,
strahov: parseFloat(row.strahov) || 0,
canonicalFio,
});
}
const areas = Array.from(areaMap.values()).map((a) => ({
id: a.id,
name: a.name,
months: Array.from(a.months.values()).sort((a, b) => periodCompare(b.month, a.month)),
}));
res.json({ success: true, areas });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post("/api/salary/zof/assign", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { fioLetters, fioDisplay, areaId, areaName, canonicalFio } = req.body;
await query(
`INSERT INTO opof_manual_areas (fio_letters, fio_display, area_id, area_name, canonical_fio)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (fio_letters) DO UPDATE SET
fio_display = EXCLUDED.fio_display,
area_id = EXCLUDED.area_id,
area_name = EXCLUDED.area_name,
canonical_fio = EXCLUDED.canonical_fio`,
[fioLetters, fioDisplay, areaId, areaName, canonicalFio || ""]
);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.delete("/api/salary/zof/assign/:fioLetters", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
await query(`DELETE FROM opof_manual_areas WHERE fio_letters = $1`, [req.params.fioLetters]);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
function normalizePeriod(val: string | Date | null): string {
if (!val) return "00.00";
if (val instanceof Date) {
const m = String(val.getMonth() + 1).padStart(2, "0");
const y = String(val.getFullYear()).slice(-2);
return `${m}.${y}`;
}
const s = String(val).trim();
// "MM.YY" or "MM.YYYY"
if (/^\d{1,2}\.\d{2,4}$/.test(s)) {
const [mPart, yPart] = s.split(".");
const m = mPart.padStart(2, "0");
const y = yPart.slice(-2);
return `${m}.${y}`;
}
// ISO date or timestamp
const d = new Date(s);
if (!isNaN(d.getTime())) {
const m = String(d.getMonth() + 1).padStart(2, "0");
const y = String(d.getFullYear()).slice(-2);
return `${m}.${y}`;
}
return s;
}
// =====================
// Planning / Analytics
// =====================
function registerPlanningRoutes(app: Express) {
app.get("/api/planning/salary-monthly", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(`
SELECT month, SUM(baza)::numeric AS total_baza, SUM(k_vydache)::numeric AS total_kvydache
FROM opneof
WHERE deleted_at IS NULL
GROUP BY month
ORDER BY month
`);
res.json({ success: true, rows: result.rows.map((r: any) => ({ month: r.month, baza: parseFloat(r.total_baza) || 0, kVydache: parseFloat(r.total_kvydache) || 0 })) });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
export async function registerSalaryRoutes(app: Express): Promise<void> {
await ensureTables();
registerDbStatusRoutes(app);
registerNeofRoutes(app);
registerZofRoutes(app);
registerPlanningRoutes(app);
}