- Копия DI2-сервера в server/finance-di2/ с правками: schema/db-client/cache/table-config/override-tables/google-auth/audit-agent/routes - db-client — обёртка над существующим пулом server/finance/db-client (второй пул не создаётся, добавлен экспорт isConnectionError) - ai-config.ts — чистое IO конфигов из ai-agent.ts без Telegram-поллинга; роуты /api/ai/toggle и /api/ai/status удалены, без compression/startAuditScheduler/autoStartIfEnabled - Обёртка registerDi2Routes с auth-gate (authenticateToken + finance.manage), регистрация строго перед registerFinanceRoutes - Статика /di2 из dist/public-di2 перед веткой vite/static (dev и prod) - Фикс предсуществующего бага DI2: buildGlobalExclusionConditions без cats в /api/profitability
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
import { query } from "./db-client";
|
|
import { loadTableConfig } from "./table-config";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
export const INCOME_OVERRIDE_COLS = [
|
|
"zakazchik", "dogovorzakazchika", "otvetstvenniipokontraktu",
|
|
"statya", "dochka", "vnuchka", "naznachenieplatezha",
|
|
"kontragent", "dogovorkontragenta", "stavkands",
|
|
"kommentarii", "statyadop", "schetnaoplatu", "nomervhodyaschegodokumenta",
|
|
];
|
|
|
|
export const EXPENSE_OVERRIDE_COLS = [
|
|
"zakazchik", "dogovorzakazchika", "otvetstvenniipokontraktu",
|
|
"statya", "dochka", "vnuchka", "naznachenieplatezha",
|
|
"kontragent", "dogovorkontragenta", "stavkands",
|
|
"kommentarii", "statyadop", "ssilkanomervhodyaschegodokumenta",
|
|
];
|
|
|
|
async function createOverrideTables(): Promise<void> {
|
|
await query(`CREATE TABLE IF NOT EXISTS opdohodi_overrides (
|
|
id TEXT PRIMARY KEY,
|
|
category_id TEXT,
|
|
zakazchik TEXT, dogovorzakazchika TEXT, otvetstvenniipokontraktu TEXT,
|
|
statya TEXT, dochka TEXT, vnuchka TEXT, naznachenieplatezha TEXT,
|
|
kontragent TEXT, dogovorkontragenta TEXT, stavkands TEXT,
|
|
kommentarii TEXT, statyadop TEXT, schetnaoplatu TEXT,
|
|
nomervhodyaschegodokumenta TEXT,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)`);
|
|
|
|
await query(`CREATE TABLE IF NOT EXISTS oprashodi_overrides (
|
|
id TEXT PRIMARY KEY,
|
|
category_id TEXT,
|
|
zakazchik TEXT, dogovorzakazchika TEXT, otvetstvenniipokontraktu TEXT,
|
|
statya TEXT, dochka TEXT, vnuchka TEXT, naznachenieplatezha TEXT,
|
|
kontragent TEXT, dogovorkontragenta TEXT, stavkands TEXT,
|
|
kommentarii TEXT, statyadop TEXT, ssilkanomervhodyaschegodokumenta TEXT,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
)`);
|
|
}
|
|
|
|
async function createEffectiveView(
|
|
sourceTable: string,
|
|
overrideTable: string,
|
|
viewName: string,
|
|
overrideCols: string[]
|
|
): Promise<void> {
|
|
const schemaResult = await query(
|
|
`SELECT column_name FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = $1
|
|
ORDER BY ordinal_position`,
|
|
[sourceTable]
|
|
);
|
|
|
|
const allCols: string[] = schemaResult.rows.map((r: any) => r.column_name);
|
|
if (allCols.length === 0) {
|
|
console.warn(`[override-tables] Table ${sourceTable} not found in schema, skipping view ${viewName}`);
|
|
return;
|
|
}
|
|
|
|
const overrideColSet = new Set(overrideCols);
|
|
const selectExprs = allCols.map(col => {
|
|
if (overrideColSet.has(col)) {
|
|
return `COALESCE(ov."${col}", o."${col}") AS "${col}"`;
|
|
}
|
|
return `o."${col}"`;
|
|
});
|
|
selectExprs.push(`ov.category_id AS "_override_category_id"`);
|
|
|
|
const viewSQL = `
|
|
CREATE OR REPLACE VIEW "${viewName}" AS
|
|
SELECT ${selectExprs.join(",\n ")}
|
|
FROM "${sourceTable}" o
|
|
LEFT JOIN "${overrideTable}" ov ON o.id = ov.id
|
|
`;
|
|
|
|
await query(viewSQL);
|
|
console.log(`[override-tables] View ${viewName} created/updated`);
|
|
}
|
|
|
|
export async function initOverrideTables(): Promise<void> {
|
|
try {
|
|
const cfg = loadTableConfig();
|
|
await createOverrideTables();
|
|
await Promise.all([
|
|
createEffectiveView(cfg.incomeTable, "opdohodi_overrides", "vw_income_effective", INCOME_OVERRIDE_COLS),
|
|
createEffectiveView(cfg.expenseTable, "oprashodi_overrides", "vw_expense_effective", EXPENSE_OVERRIDE_COLS),
|
|
]);
|
|
console.log("[override-tables] Initialization complete");
|
|
} catch (err) {
|
|
console.error("[override-tables] Init failed:", err);
|
|
}
|
|
}
|
|
|
|
export async function recreateEffectiveViews(): Promise<void> {
|
|
const cfg = loadTableConfig();
|
|
await Promise.all([
|
|
createEffectiveView(cfg.incomeTable, "opdohodi_overrides", "vw_income_effective", INCOME_OVERRIDE_COLS),
|
|
createEffectiveView(cfg.expenseTable, "oprashodi_overrides", "vw_expense_effective", EXPENSE_OVERRIDE_COLS),
|
|
]);
|
|
}
|
|
|
|
export async function saveIncomeOverride(
|
|
recordId: string,
|
|
colValues: Record<string, string>,
|
|
categoryId: string
|
|
): Promise<void> {
|
|
const params: unknown[] = [recordId, categoryId];
|
|
for (const col of INCOME_OVERRIDE_COLS) {
|
|
params.push(colValues[col] ?? null);
|
|
}
|
|
const colDefs = `"id", "category_id", ${INCOME_OVERRIDE_COLS.map(c => `"${c}"`).join(", ")}`;
|
|
const valuePhs = params.map((_, i) => `$${i + 1}`).join(", ");
|
|
const updateSets = [`"category_id" = $2`, ...INCOME_OVERRIDE_COLS.map((col, i) => `"${col}" = $${i + 3}`)].join(", ");
|
|
await query(
|
|
`INSERT INTO opdohodi_overrides (${colDefs}) VALUES (${valuePhs})
|
|
ON CONFLICT (id) DO UPDATE SET ${updateSets}`,
|
|
params
|
|
);
|
|
}
|
|
|
|
export async function saveExpenseOverride(
|
|
recordId: string,
|
|
colValues: Record<string, string>,
|
|
categoryId: string
|
|
): Promise<void> {
|
|
const params: unknown[] = [recordId, categoryId];
|
|
for (const col of EXPENSE_OVERRIDE_COLS) {
|
|
params.push(colValues[col] ?? null);
|
|
}
|
|
const colDefs = `"id", "category_id", ${EXPENSE_OVERRIDE_COLS.map(c => `"${c}"`).join(", ")}`;
|
|
const valuePhs = params.map((_, i) => `$${i + 1}`).join(", ");
|
|
const updateSets = [`"category_id" = $2`, ...EXPENSE_OVERRIDE_COLS.map((col, i) => `"${col}" = $${i + 3}`)].join(", ");
|
|
await query(
|
|
`INSERT INTO oprashodi_overrides (${colDefs}) VALUES (${valuePhs})
|
|
ON CONFLICT (id) DO UPDATE SET ${updateSets}`,
|
|
params
|
|
);
|
|
}
|
|
|
|
export async function deleteIncomeOverride(recordId: string): Promise<void> {
|
|
await query(`DELETE FROM opdohodi_overrides WHERE id = $1`, [recordId]);
|
|
}
|
|
|
|
export async function deleteExpenseOverride(recordId: string): Promise<void> {
|
|
await query(`DELETE FROM oprashodi_overrides WHERE id = $1`, [recordId]);
|
|
}
|
|
|
|
export async function getAllIncomeOverrides(): Promise<Array<{ id: string; categoryId: string }>> {
|
|
const result = await query(`SELECT id, category_id FROM opdohodi_overrides WHERE category_id IS NOT NULL`);
|
|
return result.rows.map((r: any) => ({ id: String(r.id), categoryId: String(r.category_id) }));
|
|
}
|
|
|
|
export async function getAllExpenseOverrides(): Promise<Array<{ id: string; categoryId: string }>> {
|
|
const result = await query(`SELECT id, category_id FROM oprashodi_overrides WHERE category_id IS NOT NULL`);
|
|
return result.rows.map((r: any) => ({ id: String(r.id), categoryId: String(r.category_id) }));
|
|
}
|
|
|
|
export function extractColumnValuesFromCategoryPath(
|
|
path: Array<{ rules: Array<{ column: string; operator: string; value: string }> }>,
|
|
allowedCols: string[]
|
|
): Record<string, string> {
|
|
const allowed = new Set(allowedCols);
|
|
const result: Record<string, string> = {};
|
|
for (const cat of path) {
|
|
for (const rule of cat.rules) {
|
|
if (rule.operator === "equals" && allowed.has(rule.column)) {
|
|
result[rule.column] = rule.value;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function migrateJsonOverrides(
|
|
jsonFilePath: string,
|
|
findIncomeCategory: (id: string) => { cat: any; path: any[] } | null,
|
|
findExpenseCategory: (id: string) => { cat: any; path: any[] } | null
|
|
): Promise<void> {
|
|
if (!fs.existsSync(jsonFilePath)) return;
|
|
|
|
const migratedPath = jsonFilePath + ".migrated";
|
|
if (fs.existsSync(migratedPath)) return;
|
|
|
|
try {
|
|
const incomeCount = await query(`SELECT COUNT(*) as cnt FROM opdohodi_overrides`);
|
|
const expenseCount = await query(`SELECT COUNT(*) as cnt FROM oprashodi_overrides`);
|
|
if (Number(incomeCount.rows[0].cnt) > 0 || Number(expenseCount.rows[0].cnt) > 0) {
|
|
console.log("[override-tables] Override tables already have data, skipping migration");
|
|
fs.renameSync(jsonFilePath, migratedPath);
|
|
return;
|
|
}
|
|
|
|
const raw = fs.readFileSync(jsonFilePath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
const income: Record<string, string> = parsed.income || {};
|
|
const expense: Record<string, string> = parsed.expense || {};
|
|
|
|
let migratedIncome = 0;
|
|
let migratedExpense = 0;
|
|
|
|
for (const [recordId, categoryId] of Object.entries(income)) {
|
|
try {
|
|
const found = findIncomeCategory(categoryId);
|
|
if (!found) continue;
|
|
const colValues = extractColumnValuesFromCategoryPath(found.path, INCOME_OVERRIDE_COLS);
|
|
await saveIncomeOverride(recordId, colValues, categoryId);
|
|
migratedIncome++;
|
|
} catch (err) {
|
|
console.warn(`[override-tables] Migration failed for income record ${recordId}:`, err);
|
|
}
|
|
}
|
|
|
|
for (const [recordId, categoryId] of Object.entries(expense)) {
|
|
try {
|
|
const found = findExpenseCategory(categoryId);
|
|
if (!found) continue;
|
|
const colValues = extractColumnValuesFromCategoryPath(found.path, EXPENSE_OVERRIDE_COLS);
|
|
await saveExpenseOverride(recordId, colValues, categoryId);
|
|
migratedExpense++;
|
|
} catch (err) {
|
|
console.warn(`[override-tables] Migration failed for expense record ${recordId}:`, err);
|
|
}
|
|
}
|
|
|
|
fs.renameSync(jsonFilePath, migratedPath);
|
|
console.log(`[override-tables] Migration complete: ${migratedIncome} income, ${migratedExpense} expense overrides migrated`);
|
|
} catch (err) {
|
|
console.error("[override-tables] Migration error:", err);
|
|
}
|
|
}
|