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 }>(); 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(); 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 }>(); 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 { await ensureTables(); registerDbStatusRoutes(app); registerNeofRoutes(app); registerZofRoutes(app); registerPlanningRoutes(app); }