- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
347 lines
12 KiB
TypeScript
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);
|
|
}
|