fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View 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 };
}

38
server/finance/cache.ts Normal file
View File

@@ -0,0 +1,38 @@
type CacheEntry<T> = { data: T; expiresAt: number };
const store = new Map<string, CacheEntry<any>>();
const DEFAULT_TTL_MS = 60 * 1000; // 60 seconds
export function get<T>(key: string): T | undefined {
const entry = store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return undefined;
}
return entry.data;
}
export function set<T>(key: string, data: T, ttlMs = DEFAULT_TTL_MS): void {
store.set(key, { data, expiresAt: Date.now() + ttlMs });
}
export function invalidate(keyPattern?: string): void {
if (!keyPattern) {
store.clear();
return;
}
for (const key of store.keys()) {
if (key.includes(keyPattern)) {
store.delete(key);
}
}
}
export function invalidatePrefix(prefix: string): void {
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
store.delete(key);
}
}
}

View File

@@ -0,0 +1,369 @@
import { query } from "./db-client";
import { getTableConfig } from "./table-config";
import { storage } from "../storage";
import { sql } from "drizzle-orm";
import { db } from "../db";
import { formatFieldValueForTitle, buildTableRowMap } from "../routes/shared";
import { findCustomerByInn } from "./sync-utils";
const LEGAL_ENTITY_FORM_ID = 7;
const CUSTOMER_FORM_ID = 8;
const OMEGA_PROM_NAME = "ОМЕГАПРОМ";
export interface ParsedContract {
raw: string;
number: string;
date: string; // YYYY-MM-DD
inn: string;
}
export interface ContractSyncItem {
raw: string;
number: string;
date: string;
inn: string;
customerTaskId?: number;
}
export interface SyncApplyContract {
raw: string;
number: string;
date: string;
inn: string;
customerTaskId: number;
}
function parseDate(dateStr: string): string | null {
const match = dateStr.match(/^(\d{2})\.(\d{2})\.(\d{2,4})$/);
if (!match) return null;
const [, day, month, yearStr] = match;
let year = parseInt(yearStr, 10);
if (yearStr.length === 2) {
year = year >= 50 ? 1900 + year : 2000 + year;
}
return `${year}-${month}-${day}`;
}
export function parseContractString(raw: string): { number: string; date: string } | null {
const trimmed = raw.trim();
if (!trimmed) return null;
// Extract number: everything after № (with optional space) up to " от "
const numberMatch = trimmed.match(/№\s*([^\s].*?)\s+от\s/);
if (!numberMatch) return null;
const number = numberMatch[1].trim();
// Extract date: DD.MM.YY or DD.MM.YYYY
const dateMatch = trimmed.match(/(\d{2})\.(\d{2})\.(\d{2,4})/);
if (!dateMatch) return null;
const date = parseDate(`${dateMatch[1]}.${dateMatch[2]}.${dateMatch[3]}`);
if (!date) return null;
return { number, date };
}
export async function getUniqueFinanceContracts(): Promise<{
contracts: ParsedContract[];
skipped: number;
}> {
const config = getTableConfig();
const result = await query(
`SELECT DISTINCT "dogovorzakazchika" as raw, "innzakazchika" as inn FROM "${config.incomeTable}" WHERE "dogovorzakazchika" IS NOT NULL AND "dogovorzakazchika" <> ''`
);
const contracts: ParsedContract[] = [];
let skipped = 0;
const seen = new Set<string>();
for (const row of result.rows) {
const raw = String(row.raw || "").trim();
const parsed = parseContractString(raw);
if (!parsed) {
skipped++;
continue;
}
const inn = row.inn ? String(row.inn).trim() : "";
if (!inn || inn === "0") {
skipped++;
continue;
}
const key = `${parsed.number}:${parsed.date}:${inn}`;
if (seen.has(key)) continue;
seen.add(key);
contracts.push({ raw, number: parsed.number, date: parsed.date, inn });
}
contracts.sort((a, b) => a.raw.localeCompare(b.raw));
return { contracts, skipped };
}
export async function findOmegaPromTaskId(organizationId: number): Promise<number | null> {
const result = await db.execute(sql`
SELECT id FROM tasks
WHERE form_id = ${LEGAL_ENTITY_FORM_ID}
AND organization_id = ${organizationId}
AND title ILIKE ${"%" + OMEGA_PROM_NAME + "%"}
ORDER BY id
LIMIT 1
`);
const rows = result.rows as Array<{ id: number }>;
return rows[0]?.id ?? null;
}
export async function findExistingContracts(
formId: number,
organizationId: number
): Promise<Set<string>> {
const formFields = await storage.getFormFields(formId, organizationId);
const contractField = formFields.find((f) => f.type === "contract-number");
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
if (!contractField || !customerField) {
return new Set();
}
const result = await db.execute(sql`
SELECT t.id
FROM tasks t
JOIN task_field_values v1 ON t.id = v1.task_id AND t.form_id = v1.form_id
JOIN task_field_values v2 ON t.id = v2.task_id AND t.form_id = v2.form_id
WHERE t.form_id = ${formId}
AND t.organization_id = ${organizationId}
AND v1.field_id = ${contractField.id}
AND v1.value IS NOT NULL
AND v2.field_id = ${customerField.id}
AND v2.value IS NOT NULL
`);
const existing = new Set<string>();
for (const row of result.rows as Array<{ id: number }>) {
// Load field values for this task
const values = await storage.getTaskFieldValues(row.id, organizationId);
const contractValue = values.find((v) => v.fieldId === contractField.id)?.value;
const customerValue = values.find((v) => v.fieldId === customerField.id)?.value;
if (contractValue && customerValue) {
try {
const parsed = typeof contractValue === "string" ? JSON.parse(contractValue) : contractValue;
const number = parsed?.value || "";
const customerId = String(customerValue).trim();
if (number && customerId) {
existing.add(`${number}:${customerId}`);
}
} catch {
// ignore parse errors
}
}
}
return existing;
}
export async function previewContractSync(
formId: number,
organizationId: number
): Promise<{
auto: ContractSyncItem[];
ambiguous: Array<ContractSyncItem & { reason: string }>;
existing: ContractSyncItem[];
skipped: number;
}> {
const { contracts, skipped } = await getUniqueFinanceContracts();
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
const existingContracts = await findExistingContracts(formId, organizationId);
const auto: ContractSyncItem[] = [];
const ambiguous: Array<ContractSyncItem & { reason: string }> = [];
const existing: ContractSyncItem[] = [];
for (const contract of contracts) {
const customerTaskId = await findCustomerByInn(contract.inn, organizationId);
const existingKey = `${contract.number}:${customerTaskId || ""}`;
if (existingContracts.has(existingKey)) {
existing.push(contract);
continue;
}
if (!omegaPromTaskId) {
ambiguous.push({ ...contract, reason: "Не найдено юр.лицо ОМЕГАПРОМ" });
continue;
}
if (!customerTaskId) {
ambiguous.push({ ...contract, reason: `Не найден заказчик по ИНН ${contract.inn}` });
continue;
}
auto.push({ ...contract, customerTaskId });
}
return { auto, ambiguous, existing, skipped };
}
export async function createContractTask(
formId: number,
organizationId: number,
createdBy: number,
contract: SyncApplyContract,
omegaPromTaskId: number
): Promise<{ taskId: number; title: string }> {
const formFields = await storage.getFormFields(formId, organizationId);
const contractField = formFields.find((f) => f.type === "contract-number");
const dateField = formFields.find((f) => f.type === "date");
const customerField = formFields.find((f) => f.type === "task" && f.linkedFormId === CUSTOMER_FORM_ID);
if (!contractField) {
throw new Error("В форме не найдено поле типа contract-number");
}
if (!dateField) {
throw new Error("В форме не найдено поле типа date");
}
if (!customerField) {
throw new Error("В форме не найдено поле типа task, связанное с формой Заказчик");
}
const form = await storage.getForm(formId, organizationId);
if (!form) {
throw new Error("Форма не найдена");
}
let statuses = await storage.getFormStatuses(formId, organizationId);
if (statuses.length === 0) {
const defaultStatus = await storage.createFormStatus({
formId,
organizationId,
name: "Новый",
color: "#e5e7eb",
isInitial: true,
isFinal: false,
position: 0,
} as any);
statuses = [defaultStatus];
}
const initialStatus = statuses[0];
// Build title from template or fallback
const title = form.titleTemplate
? contract.number
: `Договор №${contract.number}`;
// Extract parentTaskId from contract-number field
const contractValue = JSON.stringify({
mode: "theirs",
value: contract.number,
legalEntityTaskId: omegaPromTaskId,
});
const task = await storage.createTask({
title,
formId,
organizationId,
createdBy,
currentStatusId: initialStatus.id,
description: null,
assignedTo: null,
dueDate: null,
completedAt: null,
parentTaskId: omegaPromTaskId,
} as any);
// Insert field values
const fieldValues = [
{ fieldId: contractField.id, value: contractValue },
{ fieldId: dateField.id, value: contract.date },
{ fieldId: customerField.id, value: String(contract.customerTaskId) },
];
for (const { fieldId, value } of fieldValues) {
await storage.createTaskFieldValue({
taskId: task.id,
fieldId,
formId,
value,
});
}
// Create task relation for customer field if needed
if (customerField.taskRelationType) {
if (customerField.taskRelationType === "parent") {
await storage.upsertTaskRelation({
parentTaskId: contract.customerTaskId,
childTaskId: task.id,
fieldId: customerField.id,
organizationId,
});
} else if (customerField.taskRelationType === "child") {
await storage.upsertTaskRelation({
parentTaskId: task.id,
childTaskId: contract.customerTaskId,
fieldId: customerField.id,
organizationId,
});
}
}
// Recalculate title if template exists
if (form.titleTemplate) {
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getTaskFieldValues(task.id, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
]);
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
const { buildSystemFieldValues } = await import("../routes/shared");
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap });
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
const computedTitle = form.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValues.has(code)) return sysValues.get(code)!;
const fld = fieldCodeMap.get(code);
if (!fld) return "";
const val = valueByFieldId.get(fld.id);
return val === null || val === undefined ? "" : formatFieldValueForTitle(val, fld.type, { usersMap, rolesMap, tableRowMap }, fld.options);
});
await storage.updateTask(task.id, organizationId, { title: computedTitle });
}
return { taskId: task.id, title };
}
export async function applyContractSync(
formId: number,
organizationId: number,
createdBy: number,
contracts: SyncApplyContract[]
): Promise<{ created: number; skipped: number; errors: Array<{ raw: string; error: string }> }> {
const omegaPromTaskId = await findOmegaPromTaskId(organizationId);
if (!omegaPromTaskId) {
throw new Error("Не найдено юр.лицо ОМЕГАПРОМ");
}
const existingContracts = await findExistingContracts(formId, organizationId);
let created = 0;
let skipped = 0;
const errors: Array<{ raw: string; error: string }> = [];
for (const contract of contracts) {
try {
const key = `${contract.number}:${contract.customerTaskId}`;
if (existingContracts.has(key)) {
skipped++;
continue;
}
await createContractTask(formId, organizationId, createdBy, contract, omegaPromTaskId);
created++;
} catch (err: any) {
errors.push({ raw: contract.raw, error: err.message || "Ошибка создания" });
}
}
return { created, skipped, errors };
}

View File

@@ -0,0 +1,402 @@
import { query } from "./db-client";
import { getTableConfig } from "./table-config";
import { storage } from "../storage";
import { decrypt } from "../crypto";
import { sql } from "drizzle-orm";
import { db } from "../db";
import { formatFieldValueForTitle, buildTableRowMap } from "../routes/shared";
import { findCustomerByInn } from "./sync-utils";
export interface DadataCompany {
value: string;
data: {
inn: string;
kpp: string;
ogrn: string;
name: {
full_with_opf: string;
short_with_opf: string;
};
management?: {
name: string;
post: string;
};
address?: {
value: string;
};
state?: {
status: string;
};
};
}
export interface SyncPreviewItem {
name: string;
inn: string;
suggestions: DadataCompany[];
}
export interface SyncApplyCustomer {
name: string;
inn: string;
selectedDadata?: DadataCompany;
}
const OPF_LIST = ["ООО", "АО", "ПАО", "ЗАО", "ОАО", "ИП"];
function cleanCompanyName(name: string): string {
let cleaned = name.trim();
// Remove OPF from beginning with space after (e.g. "ООО Ромашка")
for (const opf of OPF_LIST) {
const regex = new RegExp(`^${opf}\\s+`, "i");
cleaned = cleaned.replace(regex, "");
}
// Cut everything from first occurrence of " OPF" (e.g. "АВТОДОК-СЕТЬ ООО ЭДО СБИС")
for (const opf of OPF_LIST) {
const idx = cleaned.toUpperCase().indexOf(` ${opf}`);
if (idx !== -1) {
cleaned = cleaned.substring(0, idx).trim();
break;
}
}
// Remove quotes
cleaned = cleaned.replace(/["']/g, "").trim();
return cleaned;
}
function normalizeInn(inn: string): string {
return inn.trim().toLowerCase();
}
function isValidInn(inn: string): boolean {
const trimmed = inn.trim();
if (!trimmed || trimmed === "0" || trimmed === "") return false;
return /^\d{10,12}$/.test(trimmed);
}
function getDadataValue(company: DadataCompany, key: string): string {
switch (key) {
case "name.full_with_opf":
return company.data.name?.full_with_opf || "";
case "name.short_with_opf":
return company.data.name?.short_with_opf || "";
case "inn":
return company.data.inn || "";
case "kpp":
return company.data.kpp || "";
case "ogrn":
return company.data.ogrn || "";
case "management.name":
return company.data.management?.name || "";
case "management.post":
return company.data.management?.post || "";
default:
return "";
}
}
export interface FinanceCustomer {
name: string;
inn: string;
}
export async function getUniqueFinanceCustomers(): Promise<{
customers: FinanceCustomer[];
skipped: number;
}> {
const config = getTableConfig();
const incomeResult = await query(
`SELECT DISTINCT "zakazchik" as name, "innzakazchika" as inn FROM "${config.incomeTable}" WHERE "zakazchik" IS NOT NULL AND "zakazchik" <> ''`
);
const customers: FinanceCustomer[] = [];
let skipped = 0;
const seenInns = new Set<string>();
for (const row of incomeResult.rows) {
const rawInn = row.inn ? String(row.inn).trim() : "";
if (!isValidInn(rawInn)) {
skipped++;
continue;
}
const inn = normalizeInn(rawInn);
if (seenInns.has(inn)) continue;
seenInns.add(inn);
customers.push({
name: cleanCompanyName(row.name || ""),
inn,
});
}
customers.sort((a, b) => a.inn.localeCompare(b.inn));
return { customers, skipped };
}
export async function findExistingCustomerInns(
formId: number,
organizationId: number
): Promise<Set<string>> {
const formFields = await storage.getFormFields(formId, organizationId);
const companyField = formFields.find((f) => f.type === "company");
if (!companyField || !companyField.companyAutofill) {
// Fallback: compare by name if no INN field configured
return new Set();
}
const autofill = companyField.companyAutofill as Record<string, string>;
const innFieldCode = autofill["inn"];
if (!innFieldCode) {
return new Set();
}
const innField = formFields.find((f) => f.code === innFieldCode);
if (!innField) {
return new Set();
}
const result = await db.execute(sql`
SELECT DISTINCT LOWER(TRIM(value #>> '{}')) as inn
FROM task_field_values
WHERE form_id = ${formId} AND field_id = ${innField.id} AND value IS NOT NULL AND TRIM(value #>> '{}') <> ''
`);
const existing = new Set<string>();
for (const row of result.rows as Array<{ inn: string }>) {
if (row.inn) existing.add(normalizeInn(row.inn));
}
return existing;
}
async function getDadataApiKey(organizationId: number): Promise<string | null> {
const orgService = await storage.getExternalServiceByType("dadata", organizationId);
if (orgService && orgService.apiKey) {
return decrypt(orgService.apiKey);
}
return process.env.DADATA_API_KEY || null;
}
export async function resolveCompanyViaDadata(
queryStr: string,
organizationId: number
): Promise<DadataCompany[]> {
const apiKey = await getDadataApiKey(organizationId);
if (!apiKey) {
return [];
}
const response = await fetch("https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Token ${apiKey}`,
},
body: JSON.stringify({ query: queryStr, count: 10 }),
});
if (!response.ok) {
console.error("Dadata API error:", await response.text());
return [];
}
const data = (await response.json()) as { suggestions: DadataCompany[] };
return data.suggestions || [];
}
export async function buildCustomFields(
formId: number,
organizationId: number,
company: DadataCompany
): Promise<{ title: string; customFields: Record<string, string> }> {
const formFields = await storage.getFormFields(formId, organizationId);
const companyField = formFields.find((f) => f.type === "company");
if (!companyField) {
throw new Error(`В форме ${formId} не найдено поле типа company`);
}
const cleanValue = cleanCompanyName(company.value);
const customFields: Record<string, string> = {};
customFields[`customField_${companyField.id}`] = cleanValue;
if (companyField.companyAutofill && typeof companyField.companyAutofill === "object") {
const autofill = companyField.companyAutofill as Record<string, string>;
for (const [dadataKey, targetFieldCode] of Object.entries(autofill)) {
if (!targetFieldCode || targetFieldCode === "none") continue;
const targetField = formFields.find((f) => f.code === targetFieldCode);
if (targetField) {
const value = getDadataValue(company, dadataKey);
customFields[`customField_${targetField.id}`] = value;
}
}
}
const title = cleanCompanyName(company.data.name?.short_with_opf || company.value);
return { title, customFields };
}
export async function createCustomerTask(
formId: number,
organizationId: number,
createdBy: number,
company: DadataCompany
): Promise<{ taskId: number; title: string }> {
const { title, customFields } = await buildCustomFields(formId, organizationId, company);
const form = await storage.getForm(formId, organizationId);
if (!form) {
throw new Error("Форма не найдена");
}
const statuses = await storage.getFormStatuses(formId, organizationId);
const initialStatus = statuses[0];
if (!initialStatus) {
throw new Error("У формы нет ни одного статуса");
}
const task = await storage.createTask({
title,
formId,
organizationId,
createdBy,
currentStatusId: initialStatus.id,
description: null,
assignedTo: null,
dueDate: null,
completedAt: null,
parentTaskId: null,
} as any);
const fieldMap = new Map((await storage.getFormFields(formId, organizationId)).map((f) => [f.id, f]));
for (const [fieldKey, value] of Object.entries(customFields)) {
const match = fieldKey.match(/^customField_(\d+)$/);
if (!match) continue;
const fieldId = parseInt(match[1]);
const field = fieldMap.get(fieldId);
if (!field) continue;
await storage.createTaskFieldValue({
taskId: task.id,
fieldId,
formId,
value,
});
}
if (form.titleTemplate) {
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
storage.getFormFields(formId, organizationId),
storage.getTaskFieldValues(task.id, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
]);
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
const { buildSystemFieldValues } = await import("../routes/shared");
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap });
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);
const computedTitle = form.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
if (sysValues.has(code)) return sysValues.get(code)!;
const fld = fieldCodeMap.get(code);
if (!fld) return "";
const val = valueByFieldId.get(fld.id);
return val === null || val === undefined ? "" : formatFieldValueForTitle(val, fld.type, { usersMap, rolesMap, tableRowMap }, fld.options);
});
await storage.updateTask(task.id, organizationId, { title: computedTitle });
}
return { taskId: task.id, title };
}
function matchByName(suggestions: DadataCompany[], customerName: string): DadataCompany | null {
const target = cleanCompanyName(customerName).toUpperCase();
const matches = suggestions.filter((s) => {
const candidate = cleanCompanyName(s.value).toUpperCase();
return candidate === target;
});
if (matches.length === 1) return matches[0];
return null;
}
export async function previewCustomerSync(
formId: number,
organizationId: number
): Promise<{
auto: Array<{ name: string; inn: string; company: DadataCompany }>;
ambiguous: SyncPreviewItem[];
existing: Array<{ name: string; inn: string }>;
skipped: number;
}> {
const { customers, skipped } = await getUniqueFinanceCustomers();
const existingInns = await findExistingCustomerInns(formId, organizationId);
const auto: Array<{ name: string; inn: string; company: DadataCompany }> = [];
const ambiguous: SyncPreviewItem[] = [];
const existing: Array<{ name: string; inn: string }> = [];
for (const customer of customers) {
if (existingInns.has(customer.inn)) {
existing.push(customer);
continue;
}
let suggestions = await resolveCompanyViaDadata(customer.inn, organizationId);
// Filter by exact INN match — DaData sometimes returns similar INNs (e.g. 10-digit vs 12-digit)
suggestions = suggestions.filter((s) => normalizeInn(s.data.inn || "") === customer.inn);
if (suggestions.length === 1) {
auto.push({ name: customer.name, inn: customer.inn, company: suggestions[0] });
} else if (suggestions.length > 1) {
const matched = matchByName(suggestions, customer.name);
if (matched) {
auto.push({ name: customer.name, inn: customer.inn, company: matched });
} else {
ambiguous.push({ name: customer.name, inn: customer.inn, suggestions });
}
} else {
ambiguous.push({ name: customer.name, inn: customer.inn, suggestions: [] });
}
}
return { auto, ambiguous, existing, skipped };
}
export async function applyCustomerSync(
formId: number,
organizationId: number,
createdBy: number,
customers: SyncApplyCustomer[]
): Promise<{ created: number; skipped: number; errors: Array<{ name: string; inn: string; error: string }> }> {
let created = 0;
let skipped = 0;
const errors: Array<{ name: string; inn: string; error: string }> = [];
for (const customer of customers) {
try {
const company = customer.selectedDadata;
if (!company) {
errors.push({ name: customer.name, inn: customer.inn, error: "Не выбрана компания" });
continue;
}
// Guard against duplicates: re-check INN before creating
const existingId = await findCustomerByInn(customer.inn, organizationId);
if (existingId) {
skipped++;
continue;
}
await createCustomerTask(formId, organizationId, createdBy, company);
created++;
} catch (err: any) {
errors.push({ name: customer.name, inn: customer.inn, error: err.message || "Ошибка создания" });
}
}
return { created, skipped, errors };
}

121
server/finance/db-client.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from "pg";
import { readFileSync, existsSync, writeFileSync } from "fs";
import { resolve } from "path";
interface DbConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean;
}
const CONFIG_PATH = resolve(process.cwd(), "data", "db-config.json");
let pool: Pool | null = null;
let currentConfig: DbConfig | null = null;
function loadConfigFromEnv(): DbConfig {
return {
host: process.env.FINANCE_DB_HOST || process.env.DB_HOST || "localhost",
port: parseInt(process.env.FINANCE_DB_PORT || process.env.DB_PORT || "5432", 10),
database: process.env.FINANCE_DB_NAME || process.env.DB_NAME || "postgres",
user: process.env.FINANCE_DB_USER || process.env.DB_USER || "postgres",
password: process.env.FINANCE_DB_PASSWORD || process.env.DB_PASSWORD || "",
ssl: process.env.FINANCE_DB_SSL === "true" || process.env.DB_SSL === "true",
};
}
function loadConfigFromFile(): DbConfig | null {
if (!existsSync(CONFIG_PATH)) return null;
try {
const raw = readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON.parse(raw) as DbConfig;
if (parsed.host && parsed.database && parsed.user) {
return parsed;
}
return null;
} catch {
return null;
}
}
function createPool(config: DbConfig): Pool {
return new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
max: 10,
connectionTimeoutMillis: 10000,
});
}
export function getPool(): Pool {
if (!pool) {
const fileConfig = loadConfigFromFile();
const envConfig = loadConfigFromEnv();
currentConfig = fileConfig || envConfig;
if (!currentConfig) {
throw new Error("Database configuration not found");
}
pool = createPool(currentConfig);
pool.on("error", (err) => {
console.error("Unexpected finance DB pool error:", err);
});
}
return pool;
}
export async function query<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
const p = getPool();
return p.query<T>(sql, params);
}
export async function testConnection(config: DbConfig): Promise<{ ok: boolean; message: string }> {
const testPool = createPool(config);
try {
const client = await testPool.connect();
try {
await client.query("SELECT 1");
return { ok: true, message: "Подключение успешно" };
} finally {
client.release();
}
} catch (err: any) {
return { ok: false, message: err?.message || "Ошибка подключения" };
} finally {
await testPool.end();
}
}
export function setConfig(config: DbConfig): void {
if (pool) {
pool.end().catch(() => {});
}
pool = null;
currentConfig = config;
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
pool = createPool(config);
pool.on("error", (err) => {
console.error("Unexpected finance DB pool error:", err);
});
}
export function getConfig(): Omit<DbConfig, "password"> | null {
if (!currentConfig) return null;
const { password: _password, ...rest } = currentConfig;
return rest;
}
export async function isConnected(): Promise<boolean> {
try {
await query("SELECT 1");
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,43 @@
import { type Express } from "express";
import { query } from "./db-client";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
export function registerOverrideRoutes(app: Express): void {
// GET /api/overrides?type=income|expense
app.get("/api/overrides", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const type = req.query.type as string;
const table = type === "expense" ? "expense_category_overrides" : "income_category_overrides";
const result = await query(`SELECT record_id, category_id, created_at FROM "${table}"`);
res.json({ success: true, overrides: result.rows });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// POST /api/overrides
app.post("/api/overrides", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { type, recordId, categoryId } = req.body;
const table = type === "expense" ? "expense_category_overrides" : "income_category_overrides";
await query(
`INSERT INTO "${table}" (record_id, category_id) VALUES ($1, $2) ON CONFLICT (record_id) DO UPDATE SET category_id = EXCLUDED.category_id`,
[recordId, categoryId]
);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// DELETE /api/overrides/:type/:recordId
app.delete("/api/overrides/:type/:recordId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const table = req.params.type === "expense" ? "expense_category_overrides" : "income_category_overrides";
await query(`DELETE FROM "${table}" WHERE record_id = $1`, [req.params.recordId]);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}

733
server/finance/routes.ts Normal file
View File

@@ -0,0 +1,733 @@
import { type Express, type Request, type Response } from "express";
import { query, testConnection, setConfig, getConfig, isConnected } from "./db-client";
import { registerSalaryRoutes } from "./salary-routes";
import { ensureFinanceTables, ensureViews } from "./views";
import { registerOverrideRoutes } from "./overrides";
import { registerTagRoutes } from "./tags";
import { registerAutoDetectRoutes } from "./auto-detect";
import { registerTimeseriesRoutes } from "./timeseries";
import * as cache from "./cache";
import {
getTableConfig,
getIncomeCategories,
setIncomeCategories,
getExpenseCategories,
setExpenseCategories,
getIncomeAutoDetectConfig,
setIncomeAutoDetectConfig,
getExpenseAutoDetectConfig,
setExpenseAutoDetectConfig,
} from "./table-config";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync";
import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync";
import { getFinanceSettings, setFinanceSettings } from "./settings";
// Rebuild status (in-memory)
let rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: null as string | null };
function buildWhere(
filters: Record<string, string>,
dateFrom?: string,
dateTo?: string,
dateColumn = "data"
): { clause: string; params: any[]; paramIndex: number } {
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
for (const [col, val] of Object.entries(filters)) {
if (!val) continue;
conditions.push(`"${col}" ILIKE $${idx}`);
params.push(`%${val}%`);
idx++;
}
if (dateFrom) {
conditions.push(`"${dateColumn}" >= $${idx}`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`"${dateColumn}" <= $${idx}`);
params.push(dateTo);
idx++;
}
const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
return { clause, params, paramIndex: idx };
}
function applyCategoryRules(row: Record<string, any>, categories: any[]): string | null {
for (const cat of categories) {
const match = cat.ruleLogic === "or"
? cat.rules.some((r: any) => matchRule(row, r))
: cat.rules.every((r: any) => matchRule(row, r));
if (match) {
if (cat.children && cat.children.length > 0) {
const child = applyCategoryRules(row, cat.children);
if (child) return child;
}
return cat.id;
}
}
return null;
}
function matchRule(row: Record<string, any>, rule: { column: string; operator: string; value: string }): boolean {
const val = row[rule.column];
const strVal = val == null ? "" : String(val);
const numVal = parseFloat(strVal);
const ruleNum = parseFloat(rule.value);
switch (rule.operator) {
case "equals": return strVal === rule.value;
case "not_equals": return strVal !== rule.value;
case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase());
case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum;
case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum;
case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum;
default: return false;
}
}
// =====================
// Connection & Config
// =====================
function registerConnectionRoutes(app: Express) {
app.get("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, config: getConfig() });
});
app.post("/api/connection/test", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
const result = await testConnection(req.body);
res.json(result);
});
app.post("/api/connection", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
setConfig(req.body);
cache.invalidate();
res.json({ success: true, message: "Конфигурация сохранена" });
});
app.get("/api/db-status", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
const connected = await isConnected().catch(() => false);
res.json({ connected });
});
app.get("/api/db/tables", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`
);
res.json({ success: true, tables: result.rows.map((r) => r.table_name) });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/db/columns/:table", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(
`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`,
[req.params.table]
);
res.json({ success: true, columns: result.rows });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/table-config", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, config: getTableConfig() });
});
app.get("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, settings: getFinanceSettings() });
});
app.post("/api/finance-settings", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
setFinanceSettings(req.body);
res.json({ success: true });
});
}
// =====================
// Income
// =====================
function registerIncomeRoutes(app: Express) {
app.get("/api/income", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
const sortBy = (req.query.sortBy as string) || "id";
const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC";
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const cacheKey = `income:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`;
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
const countResult = await query(`SELECT COUNT(*) FROM "${config.incomeTable}" ${clause}`, params);
const total = parseInt(countResult.rows[0].count, 10);
const sumResult = await query(`SELECT COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${clause}`, params);
const totalSum = parseFloat(sumResult.rows[0].total);
const offset = (page - 1) * pageSize;
const rowsResult = await query(
`SELECT * FROM "${config.incomeTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset]
);
const result = {
success: true,
rows: rowsResult.rows,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
totalSum,
};
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/income/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const categories = getIncomeCategories();
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const cacheKey = `income:tree:${dateFrom}:${dateTo}`;
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
const { clause, params } = buildWhere({}, dateFrom, dateTo);
const result = await query(
`SELECT * FROM "${config.incomeTable}" ${clause}`,
params
);
const rows = result.rows;
const treeTotals = new Map<string, { total: number; count: number; minDate?: string; maxDate?: string }>();
for (const row of rows) {
const sum = parseFloat(row[config.incomeSumColumn]) || 0;
const catId = applyCategoryRules(row, categories);
if (catId) {
const existing = treeTotals.get(catId) || { total: 0, count: 0 };
existing.total += sum;
existing.count++;
const date = row.data;
if (date) {
if (!existing.minDate || date < existing.minDate) existing.minDate = date;
if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date;
}
treeTotals.set(catId, existing);
}
}
const uncategorized = rows.reduce((acc, row) => {
const catId = applyCategoryRules(row, categories);
if (!catId) {
acc.total += parseFloat(row[config.incomeSumColumn]) || 0;
acc.count++;
}
return acc;
}, { total: 0, count: 0 });
const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0);
function buildTreeNodes(nodes: any[]): any[] {
return nodes.map((node) => {
const stats = treeTotals.get(node.id) || { total: 0, count: 0 };
return {
id: node.id,
name: node.name,
total: stats.total,
count: stats.count,
excluded: node.excluded,
autoDetected: node.autoDetected,
minDate: stats.minDate,
maxDate: stats.maxDate,
children: node.children ? buildTreeNodes(node.children) : undefined,
};
}).filter((n) => n.total > 0 || (n.children && n.children.length > 0));
}
const trees = categories.map((tree) => ({
id: tree.id,
name: tree.name,
isPrimary: tree.treeId === "primary",
total: (treeTotals.get(tree.id) || { total: 0 }).total,
count: (treeTotals.get(tree.id) || { count: 0 }).count,
nodes: buildTreeNodes(tree.children || []),
}));
const response = {
success: true,
trees,
uncategorized,
grandTotal: { total: grandTotal, count: rows.length },
};
cache.set(cacheKey, response);
res.json(response);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/income/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const column = req.query.column as string;
const search = (req.query.search as string) || "";
const context = req.query.context ? JSON.parse(req.query.context as string) : {};
if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; }
const { clause, params } = buildWhere(context);
const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : "";
if (search) params.push(`%${search}%`);
const result = await query(
`SELECT DISTINCT "${column}" FROM "${config.incomeTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`,
params
);
res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/income/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const categoryId = req.params.categoryId;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
const categories = getIncomeCategories();
function findCategory(nodes: any[], id: string): any | null {
for (const node of nodes) {
if (node.id === id) return node;
if (node.children) {
const found = findCategory(node.children, id);
if (found) return found;
}
}
return null;
}
const category = findCategory(categories, categoryId);
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
const result = await query(`SELECT * FROM "${config.incomeTable}" ${clause}`, params);
const allRows = result.rows;
const matchedRows = category
? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId)
: allRows.filter((row) => !applyCategoryRules(row, categories));
const total = matchedRows.length;
const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.incomeSumColumn]) || 0), 0);
const offset = (page - 1) * pageSize;
const rows = matchedRows.slice(offset, offset + pageSize);
res.json({
success: true,
rows,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
totalSum,
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
// =====================
// Expenses (mirror of income)
// =====================
function registerExpenseRoutes(app: Express) {
app.get("/api/expenses", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
const sortBy = (req.query.sortBy as string) || "id";
const sortDir = (req.query.sortDir as string) === "asc" ? "ASC" : "DESC";
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const cacheKey = `expenses:${page}:${pageSize}:${sortBy}:${sortDir}:${JSON.stringify(filters)}:${dateFrom}:${dateTo}`;
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
const countResult = await query(`SELECT COUNT(*) FROM "${config.expenseTable}" ${clause}`, params);
const total = parseInt(countResult.rows[0].count, 10);
const sumResult = await query(`SELECT COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${clause}`, params);
const totalSum = parseFloat(sumResult.rows[0].total);
const offset = (page - 1) * pageSize;
const rowsResult = await query(
`SELECT * FROM "${config.expenseTable}" ${clause} ORDER BY "${sortBy}" ${sortDir} LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset]
);
const result = {
success: true,
rows: rowsResult.rows,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
totalSum,
};
cache.set(cacheKey, result);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/expenses/tree-summary", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const categories = getExpenseCategories();
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const cacheKey = `expenses:tree:${dateFrom}:${dateTo}`;
const cached = cache.get(cacheKey);
if (cached) { res.json(cached); return; }
const { clause, params } = buildWhere({}, dateFrom, dateTo);
const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params);
const rows = result.rows;
const treeTotals = new Map<string, { total: number; count: number; minDate?: string; maxDate?: string }>();
for (const row of rows) {
const sum = parseFloat(row[config.expenseSumColumn]) || 0;
const catId = applyCategoryRules(row, categories);
if (catId) {
const existing = treeTotals.get(catId) || { total: 0, count: 0 };
existing.total += sum;
existing.count++;
const date = row.data;
if (date) {
if (!existing.minDate || date < existing.minDate) existing.minDate = date;
if (!existing.maxDate || date > existing.maxDate) existing.maxDate = date;
}
treeTotals.set(catId, existing);
}
}
const uncategorized = rows.reduce((acc, row) => {
const catId = applyCategoryRules(row, categories);
if (!catId) { acc.total += parseFloat(row[config.expenseSumColumn]) || 0; acc.count++; }
return acc;
}, { total: 0, count: 0 });
const grandTotal = rows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0);
function buildTreeNodes(nodes: any[]): any[] {
return nodes.map((node) => {
const stats = treeTotals.get(node.id) || { total: 0, count: 0 };
return {
id: node.id, name: node.name, total: stats.total, count: stats.count,
excluded: node.excluded, autoDetected: node.autoDetected,
minDate: stats.minDate, maxDate: stats.maxDate,
children: node.children ? buildTreeNodes(node.children) : undefined,
};
}).filter((n) => n.total > 0 || (n.children && n.children.length > 0));
}
const trees = categories.map((tree) => ({
id: tree.id, name: tree.name, isPrimary: tree.treeId === "primary",
total: (treeTotals.get(tree.id) || { total: 0 }).total,
count: (treeTotals.get(tree.id) || { count: 0 }).count,
nodes: buildTreeNodes(tree.children || []),
}));
const response = { success: true, trees, uncategorized, grandTotal: { total: grandTotal, count: rows.length } };
cache.set(cacheKey, response);
res.json(response);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/expenses/column-values", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const column = req.query.column as string;
const search = (req.query.search as string) || "";
const context = req.query.context ? JSON.parse(req.query.context as string) : {};
if (!column) { res.status(400).json({ success: false, error: "column обязателен" }); return; }
const { clause, params } = buildWhere(context);
const searchClause = search ? `AND "${column}" ILIKE $${params.length + 1}` : "";
if (search) params.push(`%${search}%`);
const result = await query(
`SELECT DISTINCT "${column}" FROM "${config.expenseTable}" ${clause} ${searchClause} AND "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 20`,
params
);
res.json({ success: true, values: result.rows.map((r) => r[column]).filter(Boolean) });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/expenses/:categoryId", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const config = getTableConfig();
const categoryId = req.params.categoryId;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize as string) || 50));
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const filters: Record<string, string> = req.query.filters ? JSON.parse(req.query.filters as string) : {};
const categories = getExpenseCategories();
function findCategory(nodes: any[], id: string): any | null {
for (const node of nodes) { if (node.id === id) return node; if (node.children) { const f = findCategory(node.children, id); if (f) return f; } }
return null;
}
const category = findCategory(categories, categoryId);
const { clause, params } = buildWhere(filters, dateFrom, dateTo);
const result = await query(`SELECT * FROM "${config.expenseTable}" ${clause}`, params);
const allRows = result.rows;
const matchedRows = category
? allRows.filter((row) => applyCategoryRules(row, [category]) === categoryId)
: allRows.filter((row) => !applyCategoryRules(row, categories));
const total = matchedRows.length;
const totalSum = matchedRows.reduce((sum, row) => sum + (parseFloat(row[config.expenseSumColumn]) || 0), 0);
const offset = (page - 1) * pageSize;
const rows = matchedRows.slice(offset, offset + pageSize);
res.json({ success: true, rows, total, page, pageSize, totalPages: Math.ceil(total / pageSize), totalSum });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}
// =====================
// Categories
// =====================
function registerCategoryRoutes(app: Express) {
app.get("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, categories: getIncomeCategories() });
});
app.post("/api/income-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getIncomeCategories();
const newCat = { ...req.body, id: crypto.randomUUID(), children: [] };
current.push(newCat);
setIncomeCategories(current);
cache.invalidatePrefix("income:");
res.status(201).json({ success: true, category: newCat });
});
app.put("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getIncomeCategories();
function update(nodes: any[]): boolean {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; }
if (nodes[i].children && update(nodes[i].children)) return true;
}
return false;
}
update(current);
setIncomeCategories(current);
cache.invalidatePrefix("income:");
res.json({ success: true });
});
app.patch("/api/income-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getIncomeCategories();
function toggle(nodes: any[]): boolean {
for (const node of nodes) {
if (node.id === req.params.id) { node.excluded = !node.excluded; return true; }
if (node.children && toggle(node.children)) return true;
}
return false;
}
toggle(current);
setIncomeCategories(current);
cache.invalidatePrefix("income:");
res.json({ success: true });
});
app.delete("/api/income-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getIncomeCategories();
function remove(nodes: any[]): any[] {
return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] }));
}
setIncomeCategories(remove(current));
cache.invalidatePrefix("income:");
res.json({ success: true });
});
// Expense categories (mirror)
app.get("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, categories: getExpenseCategories() });
});
app.post("/api/expense-categories", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getExpenseCategories();
const newCat = { ...req.body, id: crypto.randomUUID(), children: [] };
current.push(newCat);
setExpenseCategories(current);
cache.invalidatePrefix("expenses:");
res.status(201).json({ success: true, category: newCat });
});
app.put("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getExpenseCategories();
function update(nodes: any[]): boolean {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === req.params.id) { nodes[i] = { ...nodes[i], ...req.body }; return true; }
if (nodes[i].children && update(nodes[i].children)) return true;
}
return false;
}
update(current);
setExpenseCategories(current);
cache.invalidatePrefix("expenses:");
res.json({ success: true });
});
app.patch("/api/expense-categories/:id/toggle-excluded", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getExpenseCategories();
function toggle(nodes: any[]): boolean {
for (const node of nodes) {
if (node.id === req.params.id) { node.excluded = !node.excluded; return true; }
if (node.children && toggle(node.children)) return true;
}
return false;
}
toggle(current);
setExpenseCategories(current);
cache.invalidatePrefix("expenses:");
res.json({ success: true });
});
app.delete("/api/expense-categories/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
const current = getExpenseCategories();
function remove(nodes: any[]): any[] {
return nodes.filter((n) => n.id !== req.params.id).map((n) => ({ ...n, children: n.children ? remove(n.children) : [] }));
}
setExpenseCategories(remove(current));
cache.invalidatePrefix("expenses:");
res.json({ success: true });
});
}
// =====================
// Cache & Admin
// =====================
function registerAdminRoutes(app: Express) {
app.post("/api/cache/invalidate", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
cache.invalidate(req.body.pattern);
res.json({ success: true });
});
app.post("/api/refresh-all", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
cache.invalidate();
rebuildStatus = { inProgress: true, pendingRebuild: true, lastRebuiltAt: null };
setTimeout(() => {
rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: new Date().toISOString() };
}, 2000);
res.json({ success: true, message: "Синхронизация запущена" });
});
app.get("/api/admin/rebuild-status", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => {
res.json({ success: true, ...rebuildStatus });
});
app.post("/api/finance/customer-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.body.formId) || 8;
const result = await previewCustomerSync(formId, req.organizationId!);
res.json({ success: true, ...result });
} catch (err: any) {
console.error("Customer sync preview error:", err);
res.status(500).json({ success: false, error: err.message || "Ошибка при анализе заказчиков" });
}
});
app.post("/api/finance/customer-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.body.formId) || 8;
const customers = (req.body.customers || []) as SyncApplyCustomer[];
const result = await applyCustomerSync(formId, req.organizationId!, req.user!.id, customers);
res.json({ success: true, ...result });
} catch (err: any) {
console.error("Customer sync apply error:", err);
res.status(500).json({ success: false, error: err.message || "Ошибка при создании заказчиков" });
}
});
// Contract sync endpoints
app.post("/api/finance/contract-sync/preview", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.body.formId) || 9;
const result = await previewContractSync(formId, req.organizationId!);
res.json({ success: true, ...result });
} catch (err: any) {
console.error("Contract sync preview error:", err);
res.status(500).json({ success: false, error: err.message || "Ошибка при анализе договоров" });
}
});
app.post("/api/finance/contract-sync/apply", authenticateToken, requirePermission('finance.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.body.formId) || 9;
const contracts = (req.body.contracts || []) as SyncApplyContract[];
const result = await applyContractSync(formId, req.organizationId!, req.user!.id, contracts);
res.json({ success: true, ...result });
} catch (err: any) {
console.error("Contract sync apply error:", err);
res.status(500).json({ success: false, error: err.message || "Ошибка при создании договоров" });
}
});
}
export async function registerFinanceRoutes(app: Express): Promise<void> {
await ensureFinanceTables();
await ensureViews();
registerConnectionRoutes(app);
registerIncomeRoutes(app);
registerExpenseRoutes(app);
registerCategoryRoutes(app);
registerAdminRoutes(app);
registerOverrideRoutes(app);
registerTagRoutes(app);
registerAutoDetectRoutes(app);
registerTimeseriesRoutes(app);
await registerSalaryRoutes(app);
}

View File

@@ -0,0 +1,346 @@
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);
}

View File

@@ -0,0 +1,36 @@
import { readFileSync, existsSync, writeFileSync } from "fs";
import { resolve } from "path";
export interface FinanceSettings {
refreshIntervalSeconds: number;
}
const SETTINGS_PATH = resolve(process.cwd(), "data", "finance-settings.json");
export function getFinanceSettings(): FinanceSettings {
if (!existsSync(SETTINGS_PATH)) {
return { refreshIntervalSeconds: 60 };
}
try {
const raw = readFileSync(SETTINGS_PATH, "utf-8");
const parsed = JSON.parse(raw) as Partial<FinanceSettings>;
return {
refreshIntervalSeconds: Math.max(
5,
Math.min(3600, parsed.refreshIntervalSeconds ?? 60)
),
};
} catch {
return { refreshIntervalSeconds: 60 };
}
}
export function setFinanceSettings(settings: FinanceSettings): void {
const validated = {
refreshIntervalSeconds: Math.max(
5,
Math.min(3600, settings.refreshIntervalSeconds ?? 60)
),
};
writeFileSync(SETTINGS_PATH, JSON.stringify(validated, null, 2), "utf-8");
}

View File

@@ -0,0 +1,36 @@
import { storage } from "../storage";
import { sql } from "drizzle-orm";
import { db } from "../db";
const CUSTOMER_FORM_ID = 8;
export async function findCustomerByInn(
inn: string,
organizationId: number
): Promise<number | null> {
const formFields = await storage.getFormFields(CUSTOMER_FORM_ID, organizationId);
const companyField = formFields.find((f) => f.type === "company");
if (!companyField || !companyField.companyAutofill) {
return null;
}
const autofill = companyField.companyAutofill as Record<string, string>;
const innFieldCode = autofill["inn"];
if (!innFieldCode) return null;
const innField = formFields.find((f) => f.code === innFieldCode);
if (!innField) return null;
const result = await db.execute(sql`
SELECT t.id
FROM tasks t
JOIN task_field_values v ON t.id = v.task_id AND t.form_id = v.form_id
WHERE t.form_id = ${CUSTOMER_FORM_ID}
AND t.organization_id = ${organizationId}
AND v.field_id = ${innField.id}
AND LOWER(TRIM(v.value #>> '{}')) = LOWER(TRIM(${inn}))
LIMIT 1
`);
const rows = result.rows as Array<{ id: number }>;
return rows[0]?.id ?? null;
}

View File

@@ -0,0 +1,114 @@
import { readFileSync, existsSync, writeFileSync } from "fs";
import { resolve } from "path";
export interface TableConfig {
incomeTable: string;
expenseTable: string;
incomeSumColumn: string;
expenseSumColumn: string;
columnLabels: Record<string, string>;
}
export interface IncomeCategoryFile {
id: string;
name: string;
rules: Array<{ column: string; operator: string; value: string }>;
ruleLogic?: "and" | "or";
excluded?: boolean;
autoDetected?: boolean;
treeId?: string;
children: IncomeCategoryFile[];
}
export interface AutoDetectTree {
id: string;
name: string;
columns: string[];
autoRefresh: boolean;
isPrimary: boolean;
mode: "split" | "merge";
filters: Array<{ column: string; operator: string; value: string }>;
filterLogic: "and" | "or";
}
export interface AutoDetectConfig {
trees: AutoDetectTree[];
}
const DATA_DIR = resolve(process.cwd(), "data");
function readJson<T>(filename: string, defaultValue: T): T {
const path = resolve(DATA_DIR, filename);
if (!existsSync(path)) return defaultValue;
try {
return JSON.parse(readFileSync(path, "utf-8")) as T;
} catch {
return defaultValue;
}
}
function writeJson<T>(filename: string, data: T): void {
const path = resolve(DATA_DIR, filename);
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
}
export function getTableConfig(): TableConfig {
return readJson<TableConfig>("table-config.json", {
incomeTable: "income",
expenseTable: "expenses",
incomeSumColumn: "summa",
expenseSumColumn: "summadokumenta",
columnLabels: {
data: "Дата",
summa: "Сумма",
summadokumenta: "Сумма документа",
zakazchik: "Заказчик",
otvetstvenniipokontraktu: "Ответственный",
statya: "Статья",
dochka: "Дочка",
vnuchka: "Внучка",
kommentarii: "Комментарий",
stavkands: "Ставка НДС",
dogovorkontragenta: "Договор контрагента",
dogovorzakazchika: "Договор заказчика",
naznachenieplatezha: "Назначение платежа",
schetnaoplatu: "Счет на оплату",
statyadop: "Статья (доп.)",
nomervhodyaschegodokumenta: "Номер вх. документа",
kontragent: "Контрагент",
ssilkanomervhodyaschegodokumenta: "Номер вх. документа",
},
});
}
export function getIncomeCategories(): IncomeCategoryFile[] {
return readJson<IncomeCategoryFile[]>("income-categories.json", []);
}
export function setIncomeCategories(categories: IncomeCategoryFile[]): void {
writeJson("income-categories.json", categories);
}
export function getExpenseCategories(): IncomeCategoryFile[] {
return readJson<IncomeCategoryFile[]>("expense-categories.json", []);
}
export function setExpenseCategories(categories: IncomeCategoryFile[]): void {
writeJson("expense-categories.json", categories);
}
export function getIncomeAutoDetectConfig(): AutoDetectConfig {
return readJson<AutoDetectConfig>("income-auto-detect-config.json", { trees: [] });
}
export function setIncomeAutoDetectConfig(config: AutoDetectConfig): void {
writeJson("income-auto-detect-config.json", config);
}
export function getExpenseAutoDetectConfig(): AutoDetectConfig {
return readJson<AutoDetectConfig>("auto-detect-config.json", { trees: [] });
}
export function setExpenseAutoDetectConfig(config: AutoDetectConfig): void {
writeJson("auto-detect-config.json", config);
}

88
server/finance/tags.ts Normal file
View File

@@ -0,0 +1,88 @@
import { type Express } from "express";
import { query } from "./db-client";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
export function registerTagRoutes(app: Express): void {
// GET /api/tags
app.get("/api/tags", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const result = await query(`SELECT id, name, color FROM node_tags ORDER BY name`);
res.json({ success: true, tags: result.rows });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// POST /api/tags
app.post("/api/tags", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { name, color } = req.body;
const result = await query(
`INSERT INTO node_tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name, color || "#2563eb"]
);
res.status(201).json({ success: true, tag: result.rows[0] });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// DELETE /api/tags/:id
app.delete("/api/tags/:id", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
await query(`DELETE FROM node_tags WHERE id = $1`, [req.params.id]);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// GET /api/tag-assignments?context=income|expense&nodeIds[]=...
app.get("/api/tag-assignments", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const context = (req.query.context as string) || "income";
const nodeIds = req.query.nodeIds as string[] || [];
let sql = `SELECT a.id, a.node_id, a.tag_id, a.context, t.name as tag_name, t.color as tag_color
FROM node_tag_assignments a
JOIN node_tags t ON t.id = a.tag_id
WHERE a.context = $1`;
const params: any[] = [context];
if (nodeIds.length > 0) {
sql += ` AND a.node_id = ANY($2::text[])`;
params.push(nodeIds);
}
const result = await query(sql, params);
res.json({ success: true, assignments: result.rows });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// POST /api/tag-assignments
app.post("/api/tag-assignments", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { nodeId, tagId, context } = req.body;
await query(
`INSERT INTO node_tag_assignments (node_id, tag_id, context) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[nodeId, tagId, context || "income"]
);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// DELETE /api/tag-assignments
app.delete("/api/tag-assignments", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { nodeId, tagId, context } = req.body;
await query(
`DELETE FROM node_tag_assignments WHERE node_id = $1 AND tag_id = $2 AND context = $3`,
[nodeId, tagId, context || "income"]
);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
}

View File

@@ -0,0 +1,70 @@
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 });
}
}

89
server/finance/views.ts Normal file
View File

@@ -0,0 +1,89 @@
import { query } from "./db-client";
import { getTableConfig } from "./table-config";
export async function ensureFinanceTables(): Promise<void> {
try {
// Overrides tables
await query(`
CREATE TABLE IF NOT EXISTS income_category_overrides (
record_id TEXT PRIMARY KEY,
category_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
CREATE TABLE IF NOT EXISTS expense_category_overrides (
record_id TEXT PRIMARY KEY,
category_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Tags tables
await query(`
CREATE TABLE IF NOT EXISTS node_tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#2563eb'
)
`);
await query(`
CREATE TABLE IF NOT EXISTS node_tag_assignments (
id SERIAL PRIMARY KEY,
node_id TEXT NOT NULL,
tag_id INTEGER NOT NULL REFERENCES node_tags(id) ON DELETE CASCADE,
context TEXT NOT NULL DEFAULT 'income',
UNIQUE(node_id, tag_id, context)
)
`);
} catch (err) {
console.error("[FINANCE] Failed to ensure metadata tables:", err);
}
}
async function tableExists(tableName: string): Promise<boolean> {
try {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
) as exists`,
[tableName]
);
return result.rows[0]?.exists === true;
} catch {
return false;
}
}
export async function ensureViews(): Promise<void> {
const config = getTableConfig();
try {
const incomeExists = await tableExists(config.incomeTable);
const expenseExists = await tableExists(config.expenseTable);
if (!incomeExists && !expenseExists) {
return;
}
if (incomeExists) {
await query(`
CREATE OR REPLACE VIEW vw_income_effective AS
SELECT t.*, o.category_id as _override_category_id
FROM "${config.incomeTable}" t
LEFT JOIN income_category_overrides o ON o.record_id = t.id::text
`);
}
if (expenseExists) {
await query(`
CREATE OR REPLACE VIEW vw_expense_effective AS
SELECT t.*, o.category_id as _override_category_id
FROM "${config.expenseTable}" t
LEFT JOIN expense_category_overrides o ON o.record_id = t.id::text
`);
}
} catch (err) {
console.error("[FINANCE] Failed to create views:", err);
}
}