fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
206
server/finance/auto-detect.ts
Normal file
206
server/finance/auto-detect.ts
Normal 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
38
server/finance/cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
369
server/finance/contract-sync.ts
Normal file
369
server/finance/contract-sync.ts
Normal 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 };
|
||||
}
|
||||
402
server/finance/customer-sync.ts
Normal file
402
server/finance/customer-sync.ts
Normal 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
121
server/finance/db-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
server/finance/overrides.ts
Normal file
43
server/finance/overrides.ts
Normal 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
733
server/finance/routes.ts
Normal 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);
|
||||
}
|
||||
346
server/finance/salary-routes.ts
Normal file
346
server/finance/salary-routes.ts
Normal 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);
|
||||
}
|
||||
36
server/finance/settings.ts
Normal file
36
server/finance/settings.ts
Normal 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");
|
||||
}
|
||||
36
server/finance/sync-utils.ts
Normal file
36
server/finance/sync-utils.ts
Normal 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;
|
||||
}
|
||||
114
server/finance/table-config.ts
Normal file
114
server/finance/table-config.ts
Normal 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
88
server/finance/tags.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
70
server/finance/timeseries.ts
Normal file
70
server/finance/timeseries.ts
Normal 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
89
server/finance/views.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user