DI2: удаление старого модуля финансов — пул БД и аудит перенесены в finance-di2

- server/finance/ (19 файлов) удалён: пул финансовой БД перенесён в server/finance-di2/db-core.ts, db-client.ts теперь реэкспортирует из ./db-core
- Шедулер аудита перенесён в server/finance-di2/audit-scheduler.ts (03:00 МСК, setInterval, без node-cron), запуск из registerDi2Routes с защитой от повторного старта
- История аудита DI2 теперь персистентна: загрузка/запись data/finance-audit-history.json в audit-agent.ts (порт из удалённого server/finance/audit.ts)
- registerFinanceRoutes убран из server/routes/index.ts, registerDi2Routes остался на его месте
- Клиент: удалены pages/finance/, components/finance/, hooks/useFinance.ts, hooks/useColumnLabels.ts, lib/format.ts, pages/salary.tsx; роуты /finance*, /income*, /expenses*, /salary и lazy-импорты убраны из App.tsx
- Навигация: «Финансы» в Sidebar и MobileBottomNav ведёт на субапп /di2 (внешняя ссылка), гейт moduleAccess.finance + finance.view сохранён
- index.css: удалён блок .hover-elevate/.hover-elevate-2/.active-elevate (использовался только старым модульным DataTable)
- FinanceBottomNav.tsx сохранён как референс для этапа 3 (.superpowers/sdd/di2-restyle/FinanceBottomNav.reference.tsx)
- Не тронуто: client-di2/, сиды прав finance.view/manage, finance_access (module-access)
This commit is contained in:
2026-09-06 21:53:47 +03:00
parent 6bcf4dfdfc
commit 66f4e5ef53
68 changed files with 212 additions and 14471 deletions

View File

@@ -47,9 +47,33 @@ interface DirectionAudit {
treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[];
}
const auditHistory: AuditResult[] = [];
const HISTORY_PATH = path.join(process.cwd(), "data", "finance-audit-history.json");
const MAX_HISTORY = 50;
let auditHistory: AuditResult[] = [];
// Персистентность истории аудита (порт из удалённого server/finance/audit.ts):
// загрузка при старте модуля, запись после каждого runIntegrityAudit.
function loadHistory(): void {
if (!fs.existsSync(HISTORY_PATH)) return;
try {
const parsed = JSON.parse(fs.readFileSync(HISTORY_PATH, "utf-8"));
if (Array.isArray(parsed)) auditHistory = parsed;
} catch {
// повреждённый файл — начинаем с чистой истории
}
}
function saveHistory(): void {
try {
fs.writeFileSync(HISTORY_PATH, JSON.stringify(auditHistory, null, 2), "utf-8");
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка сохранения истории:", err);
}
}
loadHistory();
function loadJSON(filePath: string): any {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -437,6 +461,7 @@ export async function runIntegrityAudit(): Promise<AuditResult> {
auditHistory.unshift(result);
if (auditHistory.length > MAX_HISTORY) auditHistory.pop();
saveHistory();
console.log(`[AUDIT-AGENT] Аудит завершён: ${result.summary}`);
console.log(`[AUDIT-AGENT] Доходы: БД=${income.dbCount} строк, деревья=${income.treeCount}, excluded=${income.excludedCount}, нераспр=${income.uncategorizedCount}, gap=${income.gapCount}`);

View File

@@ -0,0 +1,53 @@
// Планировщик аудита целостности DI2: ежедневно в 03:00 (МСК) + первый запуск через 10 сек после старта.
// Порт из удалённого server/finance/audit.ts (без node-cron, setInterval-проверка).
import { runIntegrityAudit, getAuditHistory } from "./audit-agent";
const TICK_MS = 60_000;
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; // МСК = UTC+3
const AUDIT_HOUR_MSK = 3;
let lastRunDay: string | null = getAuditHistory()[0]?.timestamp
? new Date(new Date(getAuditHistory()[0].timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10)
: null;
let schedulerStarted = false;
function mskToday(): string {
return new Date(Date.now() + MSK_OFFSET_MS).toISOString().slice(0, 10);
}
function mskHourNow(): number {
return new Date(Date.now() + MSK_OFFSET_MS).getUTCHours();
}
async function tick(): Promise<void> {
const today = mskToday();
if (lastRunDay === today) return; // сегодня уже запускался
if (mskHourNow() < AUDIT_HOUR_MSK) return; // ещё не наступило 03:00 по МСК
lastRunDay = today;
try {
await runIntegrityAudit();
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка при выполнении аудита:", err);
}
}
export function startAuditScheduler(): void {
if (schedulerStarted) return; // защита от повторной регистрации роутов (тесты и т.п.)
schedulerStarted = true;
setInterval(() => {
tick().catch((err) => console.error("[AUDIT-AGENT] Tick error:", err));
}, TICK_MS);
console.log("[AUDIT-AGENT] Планировщик запущен (ежедневно в 03:00 по МСК)");
// Первый запуск через 10 секунд после старта сервера
setTimeout(async () => {
try {
const result = await runIntegrityAudit();
lastRunDay = new Date(new Date(result.timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);
} catch (err) {
console.error("[AUDIT-AGENT] Ошибка при начальном аудите:", err);
}
}, 10000);
}

View File

@@ -1,5 +1,5 @@
// Обёртка над существующим пулом финансовой БД iistwin (server/finance/db-client.ts).
// DI2-код импортирует эти имена из "./db-client" — второй пул не создаётся.
// Обёртка над пулом финансовой БД (./db-core) — DI2-код импортирует эти имена из "./db-client".
// Пул перенесён из удалённого server/finance/db-client.ts (единый источник — db-core).
import type { DbConnectionConfig } from "./schema";
import {
@@ -9,12 +9,12 @@ import {
setConfig,
isConnectionError,
getPoolClient,
} from "../finance/db-client";
} from "./db-core";
export { query, testConnection, setConfig, isConnectionError, getPoolClient };
// В DI2 getConfig() всегда возвращал конфиг (не null) и включал password.
// iistwin-версия отдаёт конфиг без пароля и может вернуть null — приводим к DI2-совместимой форме.
// Ядро отдаёт конфиг без пароля и может вернуть null — приводим к DI2-совместимой форме.
export function getConfig(): DbConnectionConfig {
const cfg = financeGetConfig();
if (!cfg) {

View File

@@ -0,0 +1,139 @@
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);
}
// Клиент для ручных транзакций (BEGIN/COMMIT/ROLLBACK) — см. google-auth.ts (синхронизация зарплат)
export async function getPoolClient(): Promise<PoolClient> {
const p = getPool();
return p.connect();
}
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;
}
}
const CONNECTION_ERROR_CODES = new Set(["57P01", "57P02", "57P03", "08000", "08001", "08003", "08006"]);
export function isConnectionError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const code = (err as any).code;
if (code && CONNECTION_ERROR_CODES.has(code)) return true;
const msg = err.message.toLowerCase();
return msg.includes("econnrefused") || msg.includes("etimedout") || msg.includes("enotfound") ||
msg.includes("connection terminated") || msg.includes("connection timeout") ||
msg.includes("закрытие подключения") || msg.includes("the database system is shutting down");
}

View File

@@ -5,6 +5,7 @@ import type { Express } from "express";
import express from "express";
import { authenticateToken, requirePermission } from "../middleware/auth.middleware";
import { registerRoutes } from "./routes";
import { startAuditScheduler } from "./audit-scheduler";
const DI2_PREFIXES = [
"/api/income",
@@ -52,4 +53,8 @@ export async function registerDi2Routes(app: Express): Promise<void> {
await registerRoutes(null as any, router as any);
app.use(router);
// Планировщик аудита целостности (03:00 МСК) — запускается один раз благодаря
// флагу schedulerStarted внутри startAuditScheduler.
startAuditScheduler();
}