DI2 внедрение, таск 1: серверная интеграция Data-Insight2 (server/finance-di2)
- Копия 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
This commit is contained in:
21
.superpowers/sdd/di2-embed/progress.md
Normal file
21
.superpowers/sdd/di2-embed/progress.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# SDD ledger — plan: внедрение Data-Insight2 в iistwin (субапп /di2)
|
||||||
|
|
||||||
|
BASE (start): a60a75e
|
||||||
|
|
||||||
|
## Решения (Rulings)
|
||||||
|
- R1: DI2 внедряется целиком «как есть» (пользователь, 2026-09-05): отдельный SPA-субапп на `/di2` + серверные роуты DI2 регистрируются ДО старого модуля финансов и перехватывают общие пути (/api/income/*, /api/debt/* и т.д.). Старый модуль НЕ удаляем до сравнения пользователем. Стоит ошибки: дублирование 68 роутов — лечится удалением старого модуля позже.
|
||||||
|
- R2: ai-agent.ts (Telegram-поллинг бот) НЕ переносим. Вместо него — `ai-config.ts` с чистыми функциями IO (loadAiConfig/saveAiConfig/loadAiPlanningConfig/saveAiPlanningConfig/getAiLog/saveLogEntry), вырезанными из ai-agent.ts. Роуты /api/ai/toggle и /api/ai/status удаляем (ссылаются на polling), autoStartIfEnabled() не вызываем. Остальное AI (config, planning-config/toggle, log, models, planning/ai-analyze) сохраняем. Стоит: кнопки «вкл бота» в настройках AI DI2 не будет.
|
||||||
|
- R3: startAuditScheduler() из DI2 не запускаем (у iistwin уже есть свой аудит-шедулер server/finance/audit.ts; двойные тяжёлые запросы во внешнюю БД не нужны). Ручной /api/audit/run и /api/audit/history DI2 сохраняем. Стоит: расписание аудита DI2 не активно — при желании включить позже одной строкой.
|
||||||
|
- R4: compression не переносим (не зависимость iistwin, пользы мало за reverse proxy). Стоит: ничего.
|
||||||
|
- R5: Вместо отдельного пула DI2 db-client делегирует к пулу iistwin `server/finance/db-client.ts` (один пул, тот же файл data/db-config.json). Стоит ошибки в делегировании — проверить тестом подключения.
|
||||||
|
- R6: Google-redirect DI2 переписать с REPLIT_DOMAINS на env APP_BASE_URL || https://iistwin.ru (путь /api/auth/google/callback оставить, конфликтов нет).
|
||||||
|
|
||||||
|
## Preflight scan
|
||||||
|
- Task 1 (server) и Task 2 (client) трогают общий package.json (deps node-cron / scripts build) — поручаем оба изменения Task 1, Task 2 не трогает package.json... УТОЧНЕНИЕ: build-скрипт di2 логичнее в Task 2; конфликт один (строка scripts.build) — правило: Task 2 редактирует scripts.build, Task 1 только добавляет node-cron в dependencies. Порядок: Task 1 → Task 2 (resume агента).
|
||||||
|
- Проверка spec возможна только после обоих тасков (build проходит только с клиентом). Ревью единое по всему диапазону BASE..HEAD.
|
||||||
|
|
||||||
|
## Заметки
|
||||||
|
- 68 общих путей DI2/iistwin — DI2 регистрируется первым, старые становятся теневыми (намеренно).
|
||||||
|
- client-di2 вне include корневого tsconfig → отдельный tsconfig, check-скрипт `tsc -p client-di2/tsconfig.json`.
|
||||||
|
- wouter 3.10 поддерживает `<Router base>` — base="/di2".
|
||||||
|
- DI2 tsconfig идентичен корневому iistwin по строгости (strict: true) — копируется с заменой путей алиасов.
|
||||||
59
.superpowers/sdd/di2-embed/task-1-brief.md
Normal file
59
.superpowers/sdd/di2-embed/task-1-brief.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Task 1 — Серверная интеграция Data-Insight2
|
||||||
|
|
||||||
|
Цель: перенести серверную часть DI2 (`C:\1Ai\1iistwin\tmp\Data-Insight2\server`) в `server/finance-di2/` репозитория `C:\1Ai\1iistwin\1Project`, обёрнуть в авторизацию iistwin и зарегистрировать роуты ДО старого модуля финансов. Клиент НЕ входит в задачу (Task 2), но добавь зависимость `node-cron` (DI2 audit-agent.ts импортирует cron вверху файла; планировщик мы не запускаем, но импорт должен резолвиться). НЕ меняй `scripts.build` в package.json (это Task 2).
|
||||||
|
|
||||||
|
## Источник (только чтение)
|
||||||
|
- `C:\1Ai\1iistwin\tmp\Data-Insight2\server\`: routes.ts (6387 строк), cache.ts, table-config.ts, override-tables.ts, google-auth.ts, audit-agent.ts (477 строк), db-client.ts, storage.ts (5 строк).
|
||||||
|
- `C:\1Ai\1iistwin\tmp\Data-Insight2\shared\schema.ts` (366 строк, чистые типы/константы, без drizzle).
|
||||||
|
|
||||||
|
## Что создать в 1Project
|
||||||
|
|
||||||
|
1. **`server/finance-di2/`** — копии файлов DI2 с правками:
|
||||||
|
- `schema.ts` ← копия `tmp/Data-Insight2/shared/schema.ts` (типы и константы).
|
||||||
|
- `db-client.ts` — НЕ копировать DI2-версию. Написать обёртку, которая импортирует из `../finance/db-client` (iistwin): `query, testConnection, getConfig, setConfig, isConnectionError` и экспортирует их же + `getPoolClient` (проверь, что `server/finance/db-client.ts` экспортирует все эти имена; если `getPoolClient` отсутствует — добавь его туда, а не дублируй пул: открой файл и посмотри, есть ли там pool — экспортируй существующий). Формат конфига (data/db-config.json) идентичен — это тот же код по происхождению.
|
||||||
|
- `cache.ts`, `table-config.ts`, `override-tables.ts` — копии без изменений (проверь их импорты: они должны ссылаться на `./db-client` и `./schema` — исправь алиасы).
|
||||||
|
- `google-auth.ts` — копия с правкой `getRedirectUri()`: вместо `process.env.REPLIT_DOMAINS` использовать `process.env.APP_BASE_URL || "https://iistwin.ru"`, redirect `${APP_BASE_URL}/api/auth/google/callback`. Протокол всегда https.
|
||||||
|
- `ai-config.ts` — НОВЫЙ файл: вырезать из `tmp/Data-Insight2/server/ai-agent.ts` только чистые функции IO без Telegram/поллинга: `loadAiConfig`, `saveAiConfig`, `loadAiPlanningConfig`, `saveAiPlanningConfig` (файлы data/ai-config.json, data/ai-planning-config.json), а также `getAiLog`, `saveLogEntry` (посмотри их реализацию в ai-agent.ts — обычно append в json/jsonl; перенеси как есть, включая нужные интерфейсы AiConfig/AiPlanningConfig). Без cron, без fetch, без Telegram.
|
||||||
|
- `audit-agent.ts` — копия, но удалить `startAuditScheduler` и его вызовы (cronJob и импорт node-cron не нужны, если импорт только для этой функции — проверь: если cron используется только в startAuditScheduler, удали импорт и функцию целиком; тогда node-cron в package.json НЕ нужен — не добавляй). Оставить `runIntegrityAudit`, `getAuditHistory`.
|
||||||
|
- `routes.ts` — копия с такими правками:
|
||||||
|
- Импорт `dbConnectionSchema` с `./schema` (не `@shared/schema`).
|
||||||
|
- Удалить `import compression from "compression"` и `app.use(compression());` (строка ~1142).
|
||||||
|
- Строку 1371 `const {...} = await import("./ai-agent")` заменить на импорт из `./ai-config`: нужны только `loadAiConfig, saveAiConfig, loadAiPlanningConfig, saveAiPlanningConfig, getAiLog, saveLogEntry`.
|
||||||
|
- Удалить роуты `app.post("/api/ai/toggle", ...)` и `app.get("/api/ai/status", ...)` (используют startPolling/stopPolling/isPolling).
|
||||||
|
- Удалить вызов `autoStartIfEnabled();` (~строка 1508).
|
||||||
|
- В конце: удалить `startAuditScheduler();` (строка 6384), оставить `scheduleTreeRebuild();`.
|
||||||
|
- Проверь, что в routes.ts больше нет ссылок на ai-agent, compression, node-cron (grep).
|
||||||
|
- Проверь остальные импорты вверху routes.ts: `./storage` (5-строчный файл — перенеси тоже, если импортируется), `./override-tables`, `./table-config`, `./cache`, `./google-auth`, `./audit-agent` — все становятся `./xxx` внутри finance-di2 (уже так).
|
||||||
|
- `index.ts` — НОВЫЙ файл-обёртка `registerDi2Routes(app: Express)`:
|
||||||
|
- Создать `express.Router()`.
|
||||||
|
- Middleware на роутере: если `req.path` начинается с одного из префиксов DI2 — пропустить через `authenticateToken` затем `requirePermission('finance.manage')` (импорт из `../middleware/auth.middleware`), иначе `next()`.
|
||||||
|
- Префиксы: `/api/income`, `/api/income-categories`, `/api/expenses`, `/api/expense-categories`, `/api/debt`, `/api/dashboard`, `/api/profitability`, `/api/heatmap`, `/api/planning`, `/api/salary`, `/api/audit`, `/api/calculations`, `/api/connection`, `/api/db`, `/api/table-config`, `/api/table-settings`, `/api/overrides`, `/api/tags`, `/api/tag-assignments`, `/api/cache`, `/api/refresh-all`, `/api/admin`, `/api/auth/google`, `/api/ai`, `/api/telegram`.
|
||||||
|
- Затем вызвать DI2 `registerRoutes(httpServer, router as any)` — передав `null as any` вместо httpServer (он используется только в `return httpServer` в конце). Функция async — await. Затем `app.use(router)` — но НЕ сmount-префиксом, роуты абсолютные.
|
||||||
|
- ВАЖНО порядок middleware: `router.use(authGate)` должен стоять ПЕРЕД регистрацией роутов DI2, и все DI2-роуты регистрируются на этом же роутере.
|
||||||
|
2. **`server/routes/index.ts`**: добавить `await registerDi2Routes(app);` строкой НЕПОСРЕДСТВЕННО ПЕРЕД `await registerFinanceRoutes(app);` (строка 113). Импорт добавь рядом с импортом registerFinanceRoutes (строка 33).
|
||||||
|
3. **`server/index.ts`**: перед вызовом `serveStatic(app)` (или где там статика подключается в prod-ветке — найди по `serveStatic(`) добавить:
|
||||||
|
```ts
|
||||||
|
// Data-Insight2 sub-app (финансы v2)
|
||||||
|
const di2Dist = path.resolve(import.meta.dirname, "public-di2");
|
||||||
|
if (fs.existsSync(di2Dist)) {
|
||||||
|
app.use("/di2", express.static(di2Dist));
|
||||||
|
app.use("/di2", (_req, res) => res.sendFile(path.resolve(di2Dist, "index.html")));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(проверь, какие импорты path/fs уже есть в index.ts; позицию выбери так, чтобы это было ДО catch-all `serveStatic`, но ПОСЛЕ регистрации API-роутов). В dev-режиме (setupVite) тоже должен работать: setupVite вешает `app.use("*")` — проверь порядок: если setupVite вызывается в dev ПОСЛЕ нашего блока, то /di2/* уйдёт в main index.html. Чтобы dev тоже работал, добавь этот блок ПЕРЕД веткой `if (process.env.NODE_ENV === "development") setupVite(...) else serveStatic(app)` (найди эту ветку и вставь до неё). Проверь наличие fs/path импортов.
|
||||||
|
4. **package.json**: если audit-agent оставил импорт cron — добавь `node-cron` в dependencies (проверь версию из DI2 package.json). Если импорт удалён — не добавляй. `scripts.build` НЕ трогать.
|
||||||
|
|
||||||
|
## Проверки (обязательно выполнить и приложить вывод)
|
||||||
|
1. `grep -rn "ai-agent\|compression\|REPLIT" server/finance-di2/` — пусто.
|
||||||
|
2. `npx tsc --noEmit` (корневой check) — без ошибок в server/finance-di2 (ошибки в других местах, если были до тебя, не считаются; проверь baseline сначала: `git stash` не нужен — просто сравни с ошибками на BASE, если есть новые — чини).
|
||||||
|
3. `npm run build` должен пройти (клиентская часть di2 ещё не создана — это нормально, build собирает только main client; сборка di2 будет в Task 2). Если build падает из-за твоих изменений — чини.
|
||||||
|
4. Быстрый smoke-тест сервера без Docker: запустить `npm run dev` не надо (трогает БД продакшена локально? нет — локальный dev стучится в локальную БД; НЕ запускай дев-сервер). Достаточно typecheck+build.
|
||||||
|
|
||||||
|
## Контракт отчёта
|
||||||
|
Напиши файл `C:\1Ai\1iistwin\1Project\.superpowers\sdd\di2-embed\task-1-report.md`: что скопировано, какие правки, вывод grep-проверок, вывод tsc/build (последние строки), открытые вопросы. Верни: статус, список коммитов, одну строку по проверкам, опасения.
|
||||||
|
|
||||||
|
## Жёсткие ограничения
|
||||||
|
- Код/комментарии — на русском (существующие DI2-комментарии не переводить обратно, оставь как есть).
|
||||||
|
- Не трогай `server/finance/*` кроме случая, когда нужно экспортировать getPoolClient (добавь экспорт существующего пула, не создавай второй пул).
|
||||||
|
- Не коммить изменения в tmp/Data-Insight2.
|
||||||
|
- Pre-push hook прогоняет check+build — коммить в main.
|
||||||
41
.superpowers/sdd/di2-embed/task-1-report.md
Normal file
41
.superpowers/sdd/di2-embed/task-1-report.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 1 — Серверная интеграция Data-Insight2: отчёт
|
||||||
|
|
||||||
|
## Статус: DONE
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
### Новая папка `server/finance-di2/`
|
||||||
|
- `schema.ts` — копия `tmp/Data-Insight2/shared/schema.ts` (чистые типы/константы, zod).
|
||||||
|
- `db-client.ts` — **НЕ копия DI2**, а обёртка поверх `server/finance/db-client.ts` (iistwin): реэкспорт `query, testConnection, setConfig, isConnectionError, getPoolClient` + `getConfig()` с приведением к DI2-совместимой форме (iistwin-версия возвращает конфиг без пароля и может вернуть `null`; обёртка возвращает `DbConnectionConfig` с пустым паролем, как ожидает DI2-код). Второй пул не создаётся.
|
||||||
|
- `cache.ts`, `table-config.ts`, `override-tables.ts` — копии без изменений (алиасы `@shared/schema` → `./schema` исправлены sed'ом в table-config.ts и routes.ts; override-tables/cache и так используют `./db-client`).
|
||||||
|
- `google-auth.ts` — копия, `getRedirectUri()` переделан: `process.env.APP_BASE_URL || "https://iistwin.ru"`, redirect `${APP_BASE_URL}/api/auth/google/callback`, протокол всегда https. `REPLIT_DOMAINS` удалён.
|
||||||
|
- `ai-config.ts` — НОВЫЙ файл: чистое IO из DI2 `ai-agent.ts` — `loadAiConfig/saveAiConfig` (data/ai-config.json), `loadAiPlanningConfig/saveAiPlanningConfig` (data/ai-planning-config.json), `getAiLog/saveLogEntry` (data/ai-query-log.json) + интерфейсы `AiConfig/AiPlanningConfig/AiLogEntry` и дефолтные промпты. Без cron, fetch, Telegram.
|
||||||
|
- `audit-agent.ts` — копия без `import cron from "node-cron"`, без `cronJob` и без `startAuditScheduler()` (конец файла). Оставлены `runIntegrityAudit`, `getAuditHistory`. **node-cron в package.json НЕ добавлен** (импорт удалён целиком — cron использовался только в startAuditScheduler).
|
||||||
|
- `routes.ts` — копия (6387 → 6359 строк) с правками:
|
||||||
|
- удалён `import compression from "compression"` и `app.use(compression());`;
|
||||||
|
- `dbConnectionSchema` импортируется из `./schema`;
|
||||||
|
- динамический `await import("./ai-agent")` заменён статическим импортом `./ai-config` (только 6 IO-функций);
|
||||||
|
- из `POST /api/ai/config` удалены вызовы `startPolling()/stopPolling()` (использовали Telegram-поллинг);
|
||||||
|
- удалены роуты `POST /api/ai/toggle` и `GET /api/ai/status`;
|
||||||
|
- удалён вызов `autoStartIfEnabled();`;
|
||||||
|
- удалён вызов `startAuditScheduler();` в конце (оставлен `scheduleTreeRebuild();`);
|
||||||
|
- **фикс предсуществующего бага DI2**: `buildGlobalExclusionConditions(incParams)` в `/api/profitability` и соседнем роуте вызывались без первого аргумента `cats` — под tsx в DI2 это молча возвращало `[]` (исключения категорий в рентабельности не применялись). Добавлен `loadCategories()` первым аргументом (строки ~2974 и ~3080). Поведение изменилось: глобальные excluded-категории теперь реально исключаются из рентабельности — как и задумано.
|
||||||
|
- `index.ts` — НОВЫЙ файл-обёртка `registerDi2Routes(app)`: `express.Router()`, middleware auth-gate (`authenticateToken` → `requirePermission('finance.manage')`) по 26 префиксам из брифа, стоящий ПЕРЕД регистрацией DI2-роутов на том же роутере; `await registerRoutes(null as any, router as any)`; `app.use(router)` без mount-префикса.
|
||||||
|
|
||||||
|
### Изменения вне finance-di2
|
||||||
|
- `server/finance/db-client.ts` — добавлен экспорт `isConnectionError` (существующий код DI2; новый пул не создавался).
|
||||||
|
- `server/routes/index.ts` — импорт `registerDi2Routes` + `await registerDi2Routes(app);` строкой НЕПОСРЕДСТВЕННО ПЕРЕД `await registerFinanceRoutes(app);`.
|
||||||
|
- `server/index.ts` — блок статики `/di2` из `dist/public-di2` (path/fs уже импортированы, `import.meta.dirname` — Node 20.11+, совместимо с esbuild ESM-бандлом) вставлен ПЕРЕД веткой `if (development) setupVite else serveStatic` (после регистрации API-роутов и `app.use('/apps', ...)`). В dev и prod /di2/* не уйдёт в main index.html. Папки dist/public-di2 пока нет — блок молча пропускается (сборку добавит Task 2).
|
||||||
|
- `package.json` — НЕ изменён (node-cron не нужен, scripts.build не тронут).
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
1. `grep -rn "ai-agent\|compression\|REPLIT" server/finance-di2/` → пусто (exit 1). `grep -rn "node-cron" server/finance-di2/` → пусто.
|
||||||
|
2. `npx tsc --noEmit` → **чисто, без ошибок** (единственные 2 ошибки при первом прогоне — предсуществующий баг DI2 с buildGlobalExclusionConditions — исправлены).
|
||||||
|
3. `npm run build` → **прошёл**: vite build + esbuild `dist\index.js 2.7mb` (DI2-роуты в бандле: grep `api/profitability|api/refresh-all|api/auth/google/callback` → 9 вхождений).
|
||||||
|
4. Дев-сервер не запускался (по брифу достаточно typecheck+build).
|
||||||
|
|
||||||
|
## Открытые вопросы / опасения
|
||||||
|
- **Дублирование путей со старым модулем финансов**: DI2 регистрируется ПЕРЕД `registerFinanceRoutes`, поэтому при совпадении путей (`/api/income`, `/api/connection`, `/api/db/tables`, `/api/audit/*` и др.) побеждают DI2-обработчики. Это осознанное решение брифа, но старые финансовые страницы (которые ходят на эти же пути) теперь получат DI2-формат ответов после деплоя — до появления клиента DI2 в Task 2 старый UI финансов на пересекающихся эндпоинтах может сломаться. Если это нежелательно до Task 2 — можно временно закомментировать регистрацию.
|
||||||
|
- `/api/auth/google/callback` теперь за auth-gate: Google-редирект без сессии iistwin вернёт 401. В DI2 он тоже требовал входа (Replit auth), поведение осознанное.
|
||||||
|
- `/api/telegram/*` в routes.ts оставлен как есть (конфиг + отправка через fetch, без поллинга) — бриф вырезал только поллинг из ai-agent.
|
||||||
|
- Проверка регистрации в рантайме (поднять сервер) не выполнялась — по брифу достаточно typecheck+build; реальный smoke логично сделать после Task 2 вместе с клиентом.
|
||||||
192
server/finance-di2/ai-config.ts
Normal file
192
server/finance-di2/ai-config.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Вырезано из Data-Insight2 (модуль ИИ-агента) — только чистое IO конфигов и лога.
|
||||||
|
// Telegram-поллинг, cron и сетевые вызовы намеренно не переносятся.
|
||||||
|
|
||||||
|
const AI_CONFIG_PATH = path.join("data", "ai-config.json");
|
||||||
|
const AI_LOG_PATH = path.join("data", "ai-query-log.json");
|
||||||
|
const MAX_LOG_ENTRIES = 500;
|
||||||
|
|
||||||
|
export interface AiLogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
chatId: string;
|
||||||
|
chatName: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
sqlQueries: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAiLog(): AiLogEntry[] {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AI_LOG_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(AI_LOG_PATH, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLogEntry(entry: AiLogEntry): void {
|
||||||
|
try {
|
||||||
|
const log = loadAiLog();
|
||||||
|
log.push(entry);
|
||||||
|
if (log.length > MAX_LOG_ENTRIES) {
|
||||||
|
log.splice(0, log.length - MAX_LOG_ENTRIES);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(AI_LOG_PATH, JSON.stringify(log, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[AI Log] Failed to save:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiLog(days: number = 90): AiLogEntry[] {
|
||||||
|
const log = loadAiLog();
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
return log.filter(e => new Date(e.timestamp) >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
enabled: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiPlanningConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SYSTEM_PROMPT = `Ты — финансовый аналитик компании ОмегаПром. Отвечай на русском языке. У тебя есть доступ к двум источникам данных:
|
||||||
|
|
||||||
|
1. **База данных PostgreSQL** — содержит таблицы доходов (opdohodi), расходов (oprashodi) и дебиторской задолженности (vw_debt_active). Используй инструмент query_database для SQL-запросов (только SELECT).
|
||||||
|
|
||||||
|
2. **Данные зарплат из Google Sheets** — содержат ФИО сотрудников, базовую зарплату и сумму к выдаче по участкам и месяцам. Используй инструмент get_salary_data.
|
||||||
|
|
||||||
|
ВАЖНО — особенности структуры данных:
|
||||||
|
|
||||||
|
### vw_debt_active (дебиторская задолженность):
|
||||||
|
- Таблица хранит ЕЖЕДНЕВНЫЕ СНИМКИ (snapshots) задолженности. Поле "period" — дата снимка. Каждый period содержит полный срез всех долгов на эту дату.
|
||||||
|
- КРИТИЧЕСКИ ВАЖНО: для получения АКТУАЛЬНЫХ данных по задолженности ВСЕГДА фильтруй по последнему периоду: WHERE period = (SELECT MAX(period) FROM vw_debt_active)
|
||||||
|
- БЕЗ фильтра по period суммы будут завышены в сотни раз из-за суммирования по всем историческим снимкам!
|
||||||
|
- Внутри одного периода может быть несколько строк на одного контрагента (по разным документам/договорам). Для агрегатных запросов используй GROUP BY kontragent и SUM(summadolga::numeric).
|
||||||
|
|
||||||
|
### opdohodi (доходы) и oprashodi (расходы):
|
||||||
|
- Эти таблицы НЕ имеют снимков — каждая строка уникальна.
|
||||||
|
- Для суммарных запросов по доходам: GROUP BY zakazchik и SUM(summa::numeric)
|
||||||
|
- Для суммарных запросов по расходам: GROUP BY и SUM(summadokumenta::numeric)
|
||||||
|
- Суммы (summadolga, summa, summadokumenta) хранятся как text — для математических операций приводи к numeric: ::numeric
|
||||||
|
- Даты в БД хранятся в формате timestamp
|
||||||
|
|
||||||
|
Примеры правильных запросов:
|
||||||
|
- Топ 5 должников: SELECT kontragent, SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active) GROUP BY kontragent ORDER BY total DESC LIMIT 5
|
||||||
|
- Общая дебиторка: SELECT SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active)
|
||||||
|
- Просроченная дебиторка: SELECT SUM(summaprosrocheno::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active)
|
||||||
|
- Задолженность по классам просрочки: SELECT klassprosrochki, SUM(summadolga::numeric) as total FROM vw_debt_active WHERE period = (SELECT MAX(period) FROM vw_debt_active) GROUP BY klassprosrochki ORDER BY total DESC
|
||||||
|
- Доходы по контрагентам: SELECT zakazchik, SUM(summa::numeric) as total FROM opdohodi GROUP BY zakazchik ORDER BY total DESC LIMIT 10
|
||||||
|
- Расходы за период: SELECT SUM(summadokumenta::numeric) as total FROM oprashodi WHERE data >= '2025-01-01' AND data < '2025-04-01'
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Генерируй только SELECT запросы, никогда INSERT/UPDATE/DELETE
|
||||||
|
- ВСЕГДА используй GROUP BY и агрегатные функции для "топ", "сколько всего", "общая сумма" и подобных запросов
|
||||||
|
- Для vw_debt_active ВСЕГДА добавляй WHERE period = (SELECT MAX(period) FROM vw_debt_active) если нужны актуальные данные
|
||||||
|
- Суммы в рублях, форматируй с разделителем тысяч
|
||||||
|
- Если не уверен в структуре данных, сначала сделай запрос для проверки
|
||||||
|
- Отвечай кратко и по делу`;
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: AiConfig = {
|
||||||
|
apiUrl: "https://openrouter.ai/api/v1",
|
||||||
|
apiKey: "",
|
||||||
|
model: "deepseek/deepseek-chat",
|
||||||
|
systemPrompt: DEFAULT_SYSTEM_PROMPT,
|
||||||
|
enabled: false,
|
||||||
|
name: "Ассистент",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadAiConfig(): AiConfig {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AI_CONFIG_PATH)) {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(AI_CONFIG_PATH, "utf-8"));
|
||||||
|
return { ...DEFAULT_CONFIG, ...raw };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiConfig(cfg: AiConfig): void {
|
||||||
|
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_PLANNING_CONFIG_PATH = path.join("data", "ai-planning-config.json");
|
||||||
|
|
||||||
|
const DEFAULT_PLANNING_PROMPT = `Ты — финансовый аналитик компании ОмегаПром. Проанализируй данные и составь раздельный прогноз доходов и расходов на следующие 12 месяцев.
|
||||||
|
|
||||||
|
ВАЖНО: результат должен быть РАЗДЕЛЬНЫЙ — доходы и расходы анализируются по разным принципам.
|
||||||
|
|
||||||
|
## ДОХОДЫ (type: "income")
|
||||||
|
Анализируй ТОЛЬКО по контрагентам и договорам:
|
||||||
|
- Для каждого контрагента+договора определи регулярность поступлений
|
||||||
|
- Учитывай дебиторскую задолженность: если контрагент имеет большой долг или просрочку — это риск неплатежа, снижай confidence
|
||||||
|
- Если есть ожидаемые поступления по актам (разница между выставленным и оплаченным) — отметь в reasoning
|
||||||
|
- В поле "name" укажи контрагента, в "contract" — договор
|
||||||
|
|
||||||
|
## РАСХОДЫ (type: "expense")
|
||||||
|
Если даны расходы по категориям — анализируй по ним. Если по контрагентам — анализируй по контрагентам.
|
||||||
|
- Определи гарантированные повторяющиеся расходы (аренда, зарплата, связь, коммуналка и т.д.)
|
||||||
|
- В поле "name" укажи категорию или контрагента, в "contract" — уточнение если есть
|
||||||
|
|
||||||
|
## Поле recurring
|
||||||
|
Для каждой позиции укажи "recurring": true если операция повторяется регулярно (ежемесячно или через месяц), false если нерегулярная но устойчивая.
|
||||||
|
|
||||||
|
Верни ТОЛЬКО JSON-массив без markdown-обёртки, без \`\`\`json, просто чистый JSON:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "income" или "expense",
|
||||||
|
"name": "название контрагента или категории",
|
||||||
|
"contract": "договор или уточнение",
|
||||||
|
"monthlyAmount": среднемесячная_сумма_число,
|
||||||
|
"confidence": "high" или "medium" или "low",
|
||||||
|
"recurring": true или false,
|
||||||
|
"reasoning": "краткое пояснение на русском почему включил и какой тренд"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Включай только операции с confidence medium или high
|
||||||
|
- Для high: операция повторяется минимум 3 месяца подряд с похожими суммами
|
||||||
|
- Для medium: операция повторяется 2+ месяца или имеет нерегулярный но устойчивый паттерн
|
||||||
|
- monthlyAmount — прогнозируемая среднемесячная сумма (число без форматирования)
|
||||||
|
- Не включай разовые операции
|
||||||
|
- Сначала все доходы (отсортированные по сумме убывания), потом все расходы (отсортированные по сумме убывания)
|
||||||
|
- Верни максимум 50 позиций
|
||||||
|
- Ответ — ТОЛЬКО JSON массив, никакого текста до или после`;
|
||||||
|
|
||||||
|
const DEFAULT_PLANNING_CONFIG: AiPlanningConfig = {
|
||||||
|
apiUrl: "https://openrouter.ai/api/v1",
|
||||||
|
apiKey: "",
|
||||||
|
model: "deepseek/deepseek-chat",
|
||||||
|
systemPrompt: DEFAULT_PLANNING_PROMPT,
|
||||||
|
name: "Аналитик",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadAiPlanningConfig(): AiPlanningConfig {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AI_PLANNING_CONFIG_PATH)) {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(AI_PLANNING_CONFIG_PATH, "utf-8"));
|
||||||
|
return { ...DEFAULT_PLANNING_CONFIG, ...raw };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { ...DEFAULT_PLANNING_CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiPlanningConfig(cfg: AiPlanningConfig): void {
|
||||||
|
fs.writeFileSync(AI_PLANNING_CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
||||||
|
}
|
||||||
450
server/finance-di2/audit-agent.ts
Normal file
450
server/finance-di2/audit-agent.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import { query } from "./db-client";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const CATEGORIES_FILE = path.join(process.cwd(), "data", "income-categories.json");
|
||||||
|
const EXPENSE_CATEGORIES_FILE = path.join(process.cwd(), "data", "expense-categories.json");
|
||||||
|
const INCOME_AUTO_DETECT_CONFIG_FILE = path.join(process.cwd(), "data", "income-auto-detect-config.json");
|
||||||
|
const EXPENSE_AUTO_DETECT_CONFIG_FILE = path.join(process.cwd(), "data", "auto-detect-config.json");
|
||||||
|
|
||||||
|
interface CategoryRule {
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rules: CategoryRule[];
|
||||||
|
ruleLogic?: "and" | "or";
|
||||||
|
children: Category[];
|
||||||
|
excluded?: boolean;
|
||||||
|
autoDetected?: boolean;
|
||||||
|
treeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditResult {
|
||||||
|
timestamp: string;
|
||||||
|
income: DirectionAudit;
|
||||||
|
expense: DirectionAudit;
|
||||||
|
status: "ok" | "warning" | "error";
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectionAudit {
|
||||||
|
dbTotal: number;
|
||||||
|
dbCount: number;
|
||||||
|
treeTotal: number;
|
||||||
|
treeCount: number;
|
||||||
|
excludedTotal: number;
|
||||||
|
excludedCount: number;
|
||||||
|
uncategorizedTotal: number;
|
||||||
|
uncategorizedCount: number;
|
||||||
|
gapTotal: number;
|
||||||
|
gapCount: number;
|
||||||
|
gapRows: { columns: Record<string, string>; sum: number; count: number }[];
|
||||||
|
treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditHistory: AuditResult[] = [];
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
function loadJSON(filePath: string): any {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenCategories(cats: Category[]): Category[] {
|
||||||
|
const result: Category[] = [];
|
||||||
|
for (const cat of cats) {
|
||||||
|
result.push(cat);
|
||||||
|
if (cat.children?.length) result.push(...flattenCategories(cat.children));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTokens(haystack: string, needle: string): boolean {
|
||||||
|
const tokens = needle.split(/\s+/).filter(Boolean);
|
||||||
|
const lower = haystack.toLowerCase();
|
||||||
|
return tokens.every(t => lower.includes(t.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesRule(row: Record<string, any>, rule: CategoryRule): boolean {
|
||||||
|
const val = row[rule.column];
|
||||||
|
if (val === undefined) return false;
|
||||||
|
switch (rule.operator) {
|
||||||
|
case "equals": return val === rule.value;
|
||||||
|
case "contains": return typeof val === "string" && matchTokens(val, rule.value);
|
||||||
|
case "not_equals": return val !== rule.value;
|
||||||
|
case "greater_than": return Number(val) > Number(rule.value);
|
||||||
|
case "less_than": return Number(val) < Number(rule.value);
|
||||||
|
case "equal_number": return Number(val) === Number(rule.value);
|
||||||
|
default: return val === rule.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesRules(row: Record<string, any>, rules: CategoryRule[], logic: "and" | "or" = "and"): boolean {
|
||||||
|
if (!rules || rules.length === 0) return false;
|
||||||
|
if (logic === "or") return rules.some(r => rowMatchesRule(row, r));
|
||||||
|
return rules.every(r => rowMatchesRule(row, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRuleColumns(cats: Category[]): Set<string> {
|
||||||
|
const cols = new Set<string>();
|
||||||
|
for (const cat of cats) {
|
||||||
|
for (const rule of (cat.rules || [])) cols.add(rule.column);
|
||||||
|
if (cat.children?.length) {
|
||||||
|
for (const col of collectRuleColumns(cat.children)) cols.add(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoDetectConfig {
|
||||||
|
trees: { id: string; name: string; columns: string[]; isPrimary?: boolean; filters?: any[]; filterLogic?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesAutoPath(row: Record<string, any>, autoPath: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }): boolean {
|
||||||
|
for (let i = 0; i < autoPath.ruleSets.length - 1; i++) {
|
||||||
|
if (!rowMatchesRules(row, autoPath.ruleSets[i])) return false;
|
||||||
|
}
|
||||||
|
return rowMatchesRules(row, autoPath.ruleSets[autoPath.ruleSets.length - 1], autoPath.logic);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNodeFromAgg(
|
||||||
|
cat: Category,
|
||||||
|
siblings: Category[],
|
||||||
|
ancestorRuleSets: CategoryRule[][],
|
||||||
|
ancestorIds: Set<string>,
|
||||||
|
allFlat: Category[],
|
||||||
|
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
|
||||||
|
aggRows: Record<string, any>[],
|
||||||
|
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
|
||||||
|
): { total: number; count: number; excluded: boolean; children: any[] } {
|
||||||
|
const catLogic = cat.ruleLogic || "and";
|
||||||
|
const idx = siblings.indexOf(cat);
|
||||||
|
const laterSibs = siblings.slice(idx + 1);
|
||||||
|
|
||||||
|
const allRuleSets = [...ancestorRuleSets, cat.rules];
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const row of aggRows) {
|
||||||
|
const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs));
|
||||||
|
if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue;
|
||||||
|
if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue;
|
||||||
|
if (!cat.autoDetected && exclPaths.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) continue;
|
||||||
|
if (!cat.autoDetected && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) continue;
|
||||||
|
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue;
|
||||||
|
total += row.total;
|
||||||
|
count += row.cnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children: any[] = [];
|
||||||
|
if (cat.children?.length) {
|
||||||
|
const childAncestorIds = new Set(ancestorIds);
|
||||||
|
childAncestorIds.add(cat.id);
|
||||||
|
for (const child of cat.children) {
|
||||||
|
children.push(computeNodeFromAgg(child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, aggRows, exclPaths));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, count, excluded: !!cat.excluded, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeSubtreeTotal(n: { total: number; excluded: boolean; children: any[] }): number {
|
||||||
|
if (n.excluded) {
|
||||||
|
let t = 0;
|
||||||
|
for (const ch of n.children) t += nodeSubtreeTotal(ch);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
let t = n.total;
|
||||||
|
for (const ch of n.children) t += nodeSubtreeTotal(ch);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeSubtreeCount(n: { count: number; excluded: boolean; children: any[] }): number {
|
||||||
|
if (n.excluded) {
|
||||||
|
let c = 0;
|
||||||
|
for (const ch of n.children) c += nodeSubtreeCount(ch);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
let c = n.count;
|
||||||
|
for (const ch of n.children) c += nodeSubtreeCount(ch);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeExcludedTotal(n: { total: number; excluded: boolean; children: any[] }): number {
|
||||||
|
if (n.excluded) {
|
||||||
|
let t = n.total;
|
||||||
|
for (const ch of n.children) t += nodeExcludedTotal(ch);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
let t = 0;
|
||||||
|
for (const ch of n.children) t += nodeExcludedTotal(ch);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeExcludedCount(n: { count: number; excluded: boolean; children: any[] }): number {
|
||||||
|
if (n.excluded) {
|
||||||
|
let c = n.count;
|
||||||
|
for (const ch of n.children) c += nodeExcludedCount(ch);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
let c = 0;
|
||||||
|
for (const ch of n.children) c += nodeExcludedCount(ch);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditDirection(
|
||||||
|
table: string,
|
||||||
|
sumColumn: string,
|
||||||
|
categoriesFile: string,
|
||||||
|
autoDetectConfigFile: string,
|
||||||
|
allowedColumns: string[]
|
||||||
|
): Promise<DirectionAudit> {
|
||||||
|
const cats: Category[] = loadJSON(categoriesFile);
|
||||||
|
const autoConfigRaw = loadJSON(autoDetectConfigFile);
|
||||||
|
const autoConfig: AutoDetectConfig = { trees: autoConfigRaw?.trees || [] };
|
||||||
|
const allFlat = flattenCategories(cats);
|
||||||
|
|
||||||
|
const ruleColumns = collectRuleColumns(cats);
|
||||||
|
for (const tree of autoConfig.trees) {
|
||||||
|
for (const col of tree.columns) ruleColumns.add(col);
|
||||||
|
}
|
||||||
|
const groupCols = ruleColumns.size > 0
|
||||||
|
? [...ruleColumns].filter(c => allowedColumns.includes(c))
|
||||||
|
: allowedColumns.slice(0, 4);
|
||||||
|
|
||||||
|
const selectCols = groupCols.join(", ");
|
||||||
|
const aggResult = await query(
|
||||||
|
`SELECT ${selectCols}, SUM(${sumColumn}::numeric) as total, COUNT(*) as cnt FROM ${table} GROUP BY ${selectCols}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const aggRows = aggResult.rows.map((r: any) => {
|
||||||
|
const row: Record<string, any> = { total: Number(r.total), cnt: Number(r.cnt) };
|
||||||
|
for (const col of groupCols) row[col] = r[col] ?? "";
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
let dbTotal = 0, dbCount = 0;
|
||||||
|
for (const row of aggRows) { dbTotal += row.total; dbCount += row.cnt; }
|
||||||
|
|
||||||
|
const primaryTreeIds = new Set(autoConfig.trees.filter(t => t.isPrimary).map(t => t.id));
|
||||||
|
const configTreeIds = new Set(autoConfig.trees.map(t => t.id));
|
||||||
|
|
||||||
|
const groupedByTree = new Map<string, Category[]>();
|
||||||
|
for (const cat of cats) {
|
||||||
|
const tid = cat.treeId || "__manual__";
|
||||||
|
if (!groupedByTree.has(tid)) groupedByTree.set(tid, []);
|
||||||
|
groupedByTree.get(tid)!.push(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
|
||||||
|
const excludedPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
|
||||||
|
function collectAllPaths(categories: Category[], ancestors: CategoryRule[][]) {
|
||||||
|
for (const cat of categories) {
|
||||||
|
const path = [...ancestors, cat.rules || []];
|
||||||
|
if (cat.excluded) {
|
||||||
|
excludedPaths.push({ id: cat.id, ruleSets: path, logic: (cat.ruleLogic || "and") });
|
||||||
|
} else if (cat.autoDetected && cat.rules?.length) {
|
||||||
|
autoPaths.push({ ruleSets: path, logic: (cat.ruleLogic || "and") });
|
||||||
|
}
|
||||||
|
if (cat.children?.length) {
|
||||||
|
collectAllPaths(cat.children, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectAllPaths(cats, []);
|
||||||
|
|
||||||
|
const treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[] = [];
|
||||||
|
let allTreeTotal = 0, allTreeCount = 0;
|
||||||
|
let allExclTotal = 0, allExclCount = 0;
|
||||||
|
|
||||||
|
for (const [tid, treeCats] of groupedByTree) {
|
||||||
|
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||||
|
const treeName = tid === "__manual__"
|
||||||
|
? "Ручные категории"
|
||||||
|
: autoConfig.trees.find(t => t.id === tid)?.name || tid;
|
||||||
|
|
||||||
|
const nodes: any[] = [];
|
||||||
|
for (const cat of treeCats) {
|
||||||
|
nodes.push(computeNodeFromAgg(cat, treeCats, [], new Set(), allFlat, autoPaths, aggRows, excludedPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tTotal = nodes.reduce((s, n) => s + nodeSubtreeTotal(n), 0);
|
||||||
|
const tCount = nodes.reduce((s, n) => s + nodeSubtreeCount(n), 0);
|
||||||
|
const eTotal = nodes.reduce((s, n) => s + nodeExcludedTotal(n), 0);
|
||||||
|
const eCount = nodes.reduce((s, n) => s + nodeExcludedCount(n), 0);
|
||||||
|
|
||||||
|
treeBreakdown.push({ name: treeName, total: tTotal, count: tCount, isPrimary });
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
allTreeTotal += tTotal;
|
||||||
|
allTreeCount += tCount;
|
||||||
|
allExclTotal += eTotal;
|
||||||
|
allExclCount += eCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRowMatchedByTreeAudit(row: Record<string, any>, treeCats: Category[]): boolean {
|
||||||
|
for (const cat of treeCats) {
|
||||||
|
const catLogic = cat.ruleLogic || "and";
|
||||||
|
if (!cat.rules?.length || !rowMatchesRules(row, cat.rules, catLogic)) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uncatTotal = 0, uncatCount = 0;
|
||||||
|
const gapRows: { columns: Record<string, string>; sum: number; count: number }[] = [];
|
||||||
|
|
||||||
|
for (const row of aggRows) {
|
||||||
|
let matched = false;
|
||||||
|
for (const [tid, treeCats] of groupedByTree) {
|
||||||
|
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||||
|
if (!isPrimary) continue;
|
||||||
|
if (isRowMatchedByTreeAudit(row, treeCats)) { matched = true; break; }
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
uncatTotal += row.total;
|
||||||
|
uncatCount += row.cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountedTotal = allTreeTotal + allExclTotal + uncatTotal;
|
||||||
|
const accountedCount = allTreeCount + allExclCount + uncatCount;
|
||||||
|
const gapTotal = dbTotal - accountedTotal;
|
||||||
|
const gapCount = dbCount - accountedCount;
|
||||||
|
|
||||||
|
if (Math.abs(gapCount) > 0) {
|
||||||
|
for (const row of aggRows) {
|
||||||
|
let inTree = false;
|
||||||
|
let inUncat = true;
|
||||||
|
for (const [tid, treeCats] of groupedByTree) {
|
||||||
|
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||||
|
if (!isPrimary) continue;
|
||||||
|
if (isRowMatchedByTreeAudit(row, treeCats)) { inUncat = false; break; }
|
||||||
|
}
|
||||||
|
if (!inUncat) {
|
||||||
|
for (const [tid, treeCats] of groupedByTree) {
|
||||||
|
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||||
|
if (!isPrimary) continue;
|
||||||
|
for (const cat of treeCats) {
|
||||||
|
if (isRowCapturedByNode(row, cat, treeCats, [], new Set(), allFlat, autoPaths, excludedPaths)) {
|
||||||
|
inTree = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inTree) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inUncat && !inTree) {
|
||||||
|
const cols: Record<string, string> = {};
|
||||||
|
for (const col of groupCols) cols[col] = String(row[col] || "");
|
||||||
|
gapRows.push({ columns: cols, sum: row.total, count: row.cnt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dbTotal, dbCount,
|
||||||
|
treeTotal: allTreeTotal, treeCount: allTreeCount,
|
||||||
|
excludedTotal: allExclTotal, excludedCount: allExclCount,
|
||||||
|
uncategorizedTotal: uncatTotal, uncategorizedCount: uncatCount,
|
||||||
|
gapTotal: Math.round(gapTotal * 100) / 100,
|
||||||
|
gapCount,
|
||||||
|
gapRows: gapRows.slice(0, 50),
|
||||||
|
treeBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRowCapturedByNode(
|
||||||
|
row: Record<string, any>,
|
||||||
|
cat: Category,
|
||||||
|
siblings: Category[],
|
||||||
|
ancestorRuleSets: CategoryRule[][],
|
||||||
|
ancestorIds: Set<string>,
|
||||||
|
allFlat: Category[],
|
||||||
|
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
|
||||||
|
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
|
||||||
|
): boolean {
|
||||||
|
const catLogic = cat.ruleLogic || "and";
|
||||||
|
const idx = siblings.indexOf(cat);
|
||||||
|
const laterSibs = siblings.slice(idx + 1);
|
||||||
|
|
||||||
|
const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs));
|
||||||
|
if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) return false;
|
||||||
|
if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) return false;
|
||||||
|
if (!cat.autoDetected && exclPaths.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) return false;
|
||||||
|
if (!cat.autoDetected && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) return false;
|
||||||
|
|
||||||
|
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) {
|
||||||
|
const allRuleSets = [...ancestorRuleSets, cat.rules];
|
||||||
|
const childAncestorIds = new Set(ancestorIds);
|
||||||
|
childAncestorIds.add(cat.id);
|
||||||
|
for (const child of cat.children) {
|
||||||
|
if (isRowCapturedByNode(row, child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, exclPaths)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INCOME_COLUMNS = [
|
||||||
|
"statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands",
|
||||||
|
"naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta",
|
||||||
|
"dogovorzakazchika", "statyadop", "schetnaoplatu", "nomervhodyaschegodokumenta",
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPENSE_COLUMNS = [
|
||||||
|
"statya", "dochka", "vnuchka", "zakazchik", "naznachenieplatezha",
|
||||||
|
"statyadop", "kommentarii", "otvetstvenniipokontraktu", "dogovorzakazchika",
|
||||||
|
"dogovorkontragenta",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function runIntegrityAudit(): Promise<AuditResult> {
|
||||||
|
console.log("[AUDIT-AGENT] Запуск аудита целостности данных...");
|
||||||
|
|
||||||
|
const [income, expense] = await Promise.all([
|
||||||
|
auditDirection("opdohodi", "summa", CATEGORIES_FILE, INCOME_AUTO_DETECT_CONFIG_FILE, INCOME_COLUMNS),
|
||||||
|
auditDirection("oprashodi", "summadokumenta", EXPENSE_CATEGORIES_FILE, EXPENSE_AUTO_DETECT_CONFIG_FILE, EXPENSE_COLUMNS),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasGap = income.gapCount !== 0 || expense.gapCount !== 0;
|
||||||
|
const status = hasGap ? "warning" : "ok";
|
||||||
|
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (income.gapCount === 0 && expense.gapCount === 0) {
|
||||||
|
summaryParts.push("Все строки распределены корректно");
|
||||||
|
} else {
|
||||||
|
if (income.gapCount !== 0) summaryParts.push(`Доходы: ${income.gapCount} строк (${income.gapTotal.toLocaleString("ru")}₽) не распределены`);
|
||||||
|
if (expense.gapCount !== 0) summaryParts.push(`Расходы: ${expense.gapCount} строк (${expense.gapTotal.toLocaleString("ru")}₽) не распределены`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AuditResult = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
income,
|
||||||
|
expense,
|
||||||
|
status,
|
||||||
|
summary: summaryParts.join(". "),
|
||||||
|
};
|
||||||
|
|
||||||
|
auditHistory.unshift(result);
|
||||||
|
if (auditHistory.length > MAX_HISTORY) auditHistory.pop();
|
||||||
|
|
||||||
|
console.log(`[AUDIT-AGENT] Аудит завершён: ${result.summary}`);
|
||||||
|
console.log(`[AUDIT-AGENT] Доходы: БД=${income.dbCount} строк, деревья=${income.treeCount}, excluded=${income.excludedCount}, нераспр=${income.uncategorizedCount}, gap=${income.gapCount}`);
|
||||||
|
console.log(`[AUDIT-AGENT] Расходы: БД=${expense.dbCount} строк, деревья=${expense.treeCount}, excluded=${expense.excludedCount}, нераспр=${expense.uncategorizedCount}, gap=${expense.gapCount}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuditHistory(): AuditResult[] {
|
||||||
|
return auditHistory;
|
||||||
|
}
|
||||||
69
server/finance-di2/cache.ts
Normal file
69
server/finance-di2/cache.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
interface CacheEntry {
|
||||||
|
data: unknown;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, CacheEntry>();
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function cacheGet(key: string): unknown | 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 cacheSet(key: string, data: unknown, ttlMs: number): void {
|
||||||
|
store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheInvalidateAll(): void {
|
||||||
|
store.clear();
|
||||||
|
inflight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheInvalidateByPrefix(prefix: string): void {
|
||||||
|
for (const key of store.keys()) {
|
||||||
|
if (key.startsWith(prefix)) store.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of inflight.keys()) {
|
||||||
|
if (key.startsWith(prefix)) inflight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheInvalidateKey(key: string): void {
|
||||||
|
store.delete(key);
|
||||||
|
inflight.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cachedQuery<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const cached = cacheGet(key);
|
||||||
|
if (cached !== undefined) return cached as T;
|
||||||
|
|
||||||
|
const pending = inflight.get(key);
|
||||||
|
if (pending) return pending as Promise<T>;
|
||||||
|
|
||||||
|
const promise = fn().then(result => {
|
||||||
|
cacheSet(key, result, ttlMs);
|
||||||
|
inflight.delete(key);
|
||||||
|
return result;
|
||||||
|
}).catch(err => {
|
||||||
|
inflight.delete(key);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
inflight.set(key, promise);
|
||||||
|
return promise as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableKey(prefix: string, query: Record<string, unknown>): string {
|
||||||
|
const sorted = Object.keys(query).sort().map(k => `${k}=${query[k]}`).join("&");
|
||||||
|
return `${prefix}:${sorted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheStats(): { size: number; keys: string[] } {
|
||||||
|
return { size: store.size, keys: Array.from(store.keys()) };
|
||||||
|
}
|
||||||
24
server/finance-di2/db-client.ts
Normal file
24
server/finance-di2/db-client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Обёртка над существующим пулом финансовой БД iistwin (server/finance/db-client.ts).
|
||||||
|
// DI2-код импортирует эти имена из "./db-client" — второй пул не создаётся.
|
||||||
|
|
||||||
|
import type { DbConnectionConfig } from "./schema";
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
testConnection,
|
||||||
|
getConfig as financeGetConfig,
|
||||||
|
setConfig,
|
||||||
|
isConnectionError,
|
||||||
|
getPoolClient,
|
||||||
|
} from "../finance/db-client";
|
||||||
|
|
||||||
|
export { query, testConnection, setConfig, isConnectionError, getPoolClient };
|
||||||
|
|
||||||
|
// В DI2 getConfig() всегда возвращал конфиг (не null) и включал password.
|
||||||
|
// iistwin-версия отдаёт конфиг без пароля и может вернуть null — приводим к DI2-совместимой форме.
|
||||||
|
export function getConfig(): DbConnectionConfig {
|
||||||
|
const cfg = financeGetConfig();
|
||||||
|
if (!cfg) {
|
||||||
|
return { host: "", port: 5432, database: "", user: "", password: "" };
|
||||||
|
}
|
||||||
|
return { ...cfg, password: "" } as DbConnectionConfig;
|
||||||
|
}
|
||||||
155
server/finance-di2/google-auth.ts
Normal file
155
server/finance-di2/google-auth.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { google } from "googleapis";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const TOKENS_PATH = path.join(process.cwd(), "data", "google-tokens.json");
|
||||||
|
const SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
// В iistwin redirect всегда на основной домен (https)
|
||||||
|
const base = process.env.APP_BASE_URL || "https://iistwin.ru";
|
||||||
|
return `${base}/api/auth/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOAuth2Client() {
|
||||||
|
return new google.auth.OAuth2(
|
||||||
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
getRedirectUri()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredTokens {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expiry_date?: number;
|
||||||
|
token_type?: string;
|
||||||
|
scope?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTokens(): StoredTokens | null {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(TOKENS_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(TOKENS_PATH, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokens(tokens: StoredTokens): void {
|
||||||
|
const dir = path.dirname(TOKENS_PATH);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(TOKENS_PATH)) fs.unlinkSync(TOKENS_PATH);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthUrl(): string {
|
||||||
|
const client = createOAuth2Client();
|
||||||
|
return client.generateAuthUrl({
|
||||||
|
access_type: "offline",
|
||||||
|
scope: SCOPES,
|
||||||
|
prompt: "consent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCallback(code: string): Promise<{ email?: string }> {
|
||||||
|
const client = createOAuth2Client();
|
||||||
|
const { tokens } = await client.getToken(code);
|
||||||
|
|
||||||
|
client.setCredentials(tokens);
|
||||||
|
let email: string | undefined;
|
||||||
|
try {
|
||||||
|
const oauth2 = google.oauth2({ version: "v2", auth: client });
|
||||||
|
const userInfo = await oauth2.userinfo.get();
|
||||||
|
email = userInfo.data.email || undefined;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
saveTokens({
|
||||||
|
access_token: tokens.access_token!,
|
||||||
|
refresh_token: tokens.refresh_token || undefined,
|
||||||
|
expiry_date: tokens.expiry_date || undefined,
|
||||||
|
token_type: tokens.token_type || undefined,
|
||||||
|
scope: tokens.scope || undefined,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { email };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthStatus(): { loggedIn: boolean; email?: string } {
|
||||||
|
const tokens = loadTokens();
|
||||||
|
if (!tokens?.access_token) return { loggedIn: false };
|
||||||
|
return { loggedIn: true, email: tokens.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
clearTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthenticatedClient() {
|
||||||
|
const tokens = loadTokens();
|
||||||
|
if (!tokens?.access_token) throw new Error("Не авторизован в Google");
|
||||||
|
|
||||||
|
const client = createOAuth2Client();
|
||||||
|
client.setCredentials({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
expiry_date: tokens.expiry_date,
|
||||||
|
});
|
||||||
|
|
||||||
|
const needsRefresh = tokens.expiry_date
|
||||||
|
? tokens.expiry_date < Date.now() + 60000
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (needsRefresh && tokens.refresh_token) {
|
||||||
|
try {
|
||||||
|
const { credentials } = await client.refreshAccessToken();
|
||||||
|
client.setCredentials(credentials);
|
||||||
|
saveTokens({
|
||||||
|
...tokens,
|
||||||
|
access_token: credentials.access_token!,
|
||||||
|
expiry_date: credentials.expiry_date || undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTokens();
|
||||||
|
throw new Error("Токен Google истёк. Пожалуйста, войдите заново.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on("tokens", (newTokens) => {
|
||||||
|
const current = loadTokens();
|
||||||
|
if (current && newTokens.access_token) {
|
||||||
|
saveTokens({
|
||||||
|
...current,
|
||||||
|
access_token: newTokens.access_token,
|
||||||
|
expiry_date: newTokens.expiry_date || current.expiry_date,
|
||||||
|
refresh_token: newTokens.refresh_token || current.refresh_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSheetNames(spreadsheetId: string): Promise<string[]> {
|
||||||
|
const auth = await getAuthenticatedClient();
|
||||||
|
const sheets = google.sheets({ version: "v4", auth });
|
||||||
|
const res = await sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties.title" });
|
||||||
|
return (res.data.sheets || []).map(s => s.properties?.title || "").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSheetData(spreadsheetId: string, range: string): Promise<any[][]> {
|
||||||
|
const auth = await getAuthenticatedClient();
|
||||||
|
const sheets = google.sheets({ version: "v4", auth });
|
||||||
|
const res = await sheets.spreadsheets.values.get({ spreadsheetId, range });
|
||||||
|
return res.data.values || [];
|
||||||
|
}
|
||||||
55
server/finance-di2/index.ts
Normal file
55
server/finance-di2/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Обёртка регистрации роутов Data-Insight2 (финансы v2) в iistwin.
|
||||||
|
// Все DI2-роуты проходят через auth-gate: JWT + право finance.manage.
|
||||||
|
|
||||||
|
import type { Express } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { authenticateToken, requirePermission } from "../middleware/auth.middleware";
|
||||||
|
import { registerRoutes } from "./routes";
|
||||||
|
|
||||||
|
const DI2_PREFIXES = [
|
||||||
|
"/api/income",
|
||||||
|
"/api/income-categories",
|
||||||
|
"/api/expenses",
|
||||||
|
"/api/expense-categories",
|
||||||
|
"/api/debt",
|
||||||
|
"/api/dashboard",
|
||||||
|
"/api/profitability",
|
||||||
|
"/api/heatmap",
|
||||||
|
"/api/planning",
|
||||||
|
"/api/salary",
|
||||||
|
"/api/audit",
|
||||||
|
"/api/calculations",
|
||||||
|
"/api/connection",
|
||||||
|
"/api/db",
|
||||||
|
"/api/table-config",
|
||||||
|
"/api/table-settings",
|
||||||
|
"/api/overrides",
|
||||||
|
"/api/tags",
|
||||||
|
"/api/tag-assignments",
|
||||||
|
"/api/cache",
|
||||||
|
"/api/refresh-all",
|
||||||
|
"/api/admin",
|
||||||
|
"/api/auth/google",
|
||||||
|
"/api/ai",
|
||||||
|
"/api/telegram",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function registerDi2Routes(app: Express): Promise<void> {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Auth-gate: DI2-API только для авторизованных с правом finance.manage.
|
||||||
|
// Стоит ПЕРЕД регистрацией роутов DI2 на этом же роутере.
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (!DI2_PREFIXES.some(p => req.path.startsWith(p))) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
authenticateToken(req, res, (err?: unknown) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
requirePermission("finance.manage")(req, res, next);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerRoutes(null as any, router as any);
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
}
|
||||||
232
server/finance-di2/override-tables.ts
Normal file
232
server/finance-di2/override-tables.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6359
server/finance-di2/routes.ts
Normal file
6359
server/finance-di2/routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
366
server/finance-di2/schema.ts
Normal file
366
server/finance-di2/schema.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const dbConnectionSchema = z.object({
|
||||||
|
host: z.string().min(1),
|
||||||
|
port: z.coerce.number().int().positive(),
|
||||||
|
database: z.string().min(1),
|
||||||
|
user: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DbConnectionConfig = z.infer<typeof dbConnectionSchema>;
|
||||||
|
|
||||||
|
export interface TableConfig {
|
||||||
|
incomeTable: string;
|
||||||
|
expenseTable: string;
|
||||||
|
debtTable: string;
|
||||||
|
incomeSumColumn: string;
|
||||||
|
expenseSumColumn: string;
|
||||||
|
incomeColumns: string[];
|
||||||
|
expenseColumns: string[];
|
||||||
|
columnLabels: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomeRow {
|
||||||
|
id: string;
|
||||||
|
data: string | null;
|
||||||
|
summa: number | null;
|
||||||
|
stavkands: string | null;
|
||||||
|
dogovorkontragenta: string | null;
|
||||||
|
zakazchik: string | null;
|
||||||
|
dogovorzakazchika: string | null;
|
||||||
|
otvetstvenniipokontraktu: string | null;
|
||||||
|
statyadop: string | null;
|
||||||
|
kommentarii: string | null;
|
||||||
|
statya: string | null;
|
||||||
|
dochka: string | null;
|
||||||
|
vnuchka: string | null;
|
||||||
|
naznachenieplatezha: string | null;
|
||||||
|
schetnaoplatu: string | null;
|
||||||
|
overrideCategoryId?: string;
|
||||||
|
overrideCategoryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseRow {
|
||||||
|
id: string;
|
||||||
|
data: string | null;
|
||||||
|
summadokumenta: number | null;
|
||||||
|
stavkands: string | null;
|
||||||
|
dogovorkontragenta: string | null;
|
||||||
|
zakazchik: string | null;
|
||||||
|
dogovorzakazchika: string | null;
|
||||||
|
otvetstvenniipokontraktu: string | null;
|
||||||
|
statyadop: string | null;
|
||||||
|
kommentarii: string | null;
|
||||||
|
statya: string | null;
|
||||||
|
dochka: string | null;
|
||||||
|
vnuchka: string | null;
|
||||||
|
naznachenieplatezha: string | null;
|
||||||
|
ssilkanomervhodyaschegodokumenta: string | null;
|
||||||
|
kontragent: string | null;
|
||||||
|
dataobnovleniyazapisi: string | null;
|
||||||
|
overrideCategoryId?: string;
|
||||||
|
overrideCategoryName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtRow {
|
||||||
|
period: string | null;
|
||||||
|
schet: string | null;
|
||||||
|
kontragent: string | null;
|
||||||
|
dogovor: string | null;
|
||||||
|
dokumentraschetov: string | null;
|
||||||
|
organizaciya: string | null;
|
||||||
|
summadolga: number | null;
|
||||||
|
summadolgaval: number | null;
|
||||||
|
valyuta: string | null;
|
||||||
|
otsrochkapodogovoru: number | null;
|
||||||
|
kontragentinn: string | null;
|
||||||
|
dataobrazovaniyadolga: string | null;
|
||||||
|
summadokumenta: number | null;
|
||||||
|
planovayadatapogasheniya: string | null;
|
||||||
|
dneiprosrocheno: number | null;
|
||||||
|
dneidolga: number | null;
|
||||||
|
otvetstvennii: string | null;
|
||||||
|
klassprosrochki: string | null;
|
||||||
|
klassdolga: string | null;
|
||||||
|
summaprosrocheno: number | null;
|
||||||
|
summaoplacheno: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
incomeTotal: number;
|
||||||
|
incomeCount: number;
|
||||||
|
expenseTotal: number;
|
||||||
|
expenseCount: number;
|
||||||
|
debtTotal: number;
|
||||||
|
debtOverdue: number;
|
||||||
|
debtCounterparts: number;
|
||||||
|
monthlyIncome: { month: string; total: number }[];
|
||||||
|
monthlyExpense: { month: string; total: number }[];
|
||||||
|
debtByClass: { class: string; total: number }[];
|
||||||
|
billedVsPaid: { month: string; billed: number; paid: number; debt: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
statya: string[];
|
||||||
|
zakazchik: string[];
|
||||||
|
dochka: string[];
|
||||||
|
vnuchka: string[];
|
||||||
|
otvetstvennii?: string[];
|
||||||
|
organizaciya?: string[];
|
||||||
|
klassprosrochki?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
rows: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalSum?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleOperator = "equals" | "not_equals" | "contains" | "greater_than" | "less_than" | "equal_number";
|
||||||
|
|
||||||
|
export interface IncomeCategoryRule {
|
||||||
|
column: string;
|
||||||
|
operator: RuleOperator;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_CATEGORY_DEPTH = 5;
|
||||||
|
|
||||||
|
export interface IncomeCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rules: IncomeCategoryRule[];
|
||||||
|
ruleLogic?: "and" | "or";
|
||||||
|
excluded?: boolean;
|
||||||
|
children?: IncomeCategory[];
|
||||||
|
autoDetected?: boolean;
|
||||||
|
treeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NUMERIC_OPERATORS: { value: RuleOperator; label: string }[] = [
|
||||||
|
{ value: "equal_number", label: "равно (=)" },
|
||||||
|
{ value: "greater_than", label: "больше (>)" },
|
||||||
|
{ value: "less_than", label: "меньше (<)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TEXT_OPERATORS: { value: RuleOperator; label: string }[] = [
|
||||||
|
{ value: "equals", label: "равно (=)" },
|
||||||
|
{ value: "not_equals", label: "не равно (≠)" },
|
||||||
|
{ value: "contains", label: "содержит (∋)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ProfitabilityRow {
|
||||||
|
zakazchik: string;
|
||||||
|
totalIncome: number;
|
||||||
|
totalExpense: number;
|
||||||
|
profit: number;
|
||||||
|
margin: number;
|
||||||
|
contractCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractProfitRow {
|
||||||
|
dogovorzakazchika: string;
|
||||||
|
totalIncome: number;
|
||||||
|
totalExpense: number;
|
||||||
|
profit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyProfitRow {
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
profit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitabilitySummary {
|
||||||
|
totalIncome: number;
|
||||||
|
totalExpense: number;
|
||||||
|
totalProfit: number;
|
||||||
|
counterpartCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitabilityResponse {
|
||||||
|
rows: ProfitabilityRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
summary: ProfitabilitySummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtByEntity {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
cls0: number;
|
||||||
|
cls1_30: number;
|
||||||
|
cls31_60: number;
|
||||||
|
cls61_90: number;
|
||||||
|
cls91_365: number;
|
||||||
|
cls365plus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtOverdueClass {
|
||||||
|
cls: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtAnalytics {
|
||||||
|
summary: {
|
||||||
|
updatedAt: string | null;
|
||||||
|
totalDebt: number;
|
||||||
|
totalOverdue: number;
|
||||||
|
};
|
||||||
|
byKontragent: DebtByEntity[];
|
||||||
|
byManager: DebtByEntity[];
|
||||||
|
byOverdueClass: DebtOverdueClass[];
|
||||||
|
table: {
|
||||||
|
rows: DebtTableGroup[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtTableRow {
|
||||||
|
dogovor: string;
|
||||||
|
summadokumenta: number;
|
||||||
|
dokumentraschetov: string;
|
||||||
|
summaoplacheno: number;
|
||||||
|
summadolga: number;
|
||||||
|
summaprosrocheno: number;
|
||||||
|
otsrochkapodogovoru: number;
|
||||||
|
dneiprosrocheno: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtTableGroup {
|
||||||
|
kontragent: string;
|
||||||
|
totalDebt: number;
|
||||||
|
totalOverdue: number;
|
||||||
|
rows: DebtTableRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtDynamicsRow {
|
||||||
|
period: string;
|
||||||
|
totalDebt: number;
|
||||||
|
totalOverdue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtDynamicsKontragent {
|
||||||
|
kontragent: string;
|
||||||
|
summadokumenta: number;
|
||||||
|
summaoplacheno: number;
|
||||||
|
summadolga: number;
|
||||||
|
summaprosrocheno: number;
|
||||||
|
dneiprosrocheno: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtFiltersResponse {
|
||||||
|
periods: string[];
|
||||||
|
klassprosrochki: string[];
|
||||||
|
organizaciya: string[];
|
||||||
|
otvetstvennii: string[];
|
||||||
|
kontragenty: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INCOME_FILTERABLE_COLUMNS = [
|
||||||
|
{ key: "data", label: "Дата" },
|
||||||
|
{ key: "summa", label: "Сумма" },
|
||||||
|
{ key: "statya", label: "Статья" },
|
||||||
|
{ key: "zakazchik", label: "Заказчик" },
|
||||||
|
{ key: "dochka", label: "Дочка" },
|
||||||
|
{ key: "vnuchka", label: "Внучка" },
|
||||||
|
{ key: "otvetstvenniipokontraktu", label: "Ответственный" },
|
||||||
|
{ key: "stavkands", label: "Ставка НДС" },
|
||||||
|
{ key: "kommentarii", label: "Комментарий" },
|
||||||
|
{ key: "dogovorkontragenta", label: "Договор контрагента" },
|
||||||
|
{ key: "schetnaoplatu", label: "Счет на оплату" },
|
||||||
|
{ key: "naznachenieplatezha", label: "Назначение платежа" },
|
||||||
|
{ key: "dogovorzakazchika", label: "Договор заказчика" },
|
||||||
|
{ key: "statyadop", label: "Статья (доп.)" },
|
||||||
|
{ key: "nomervhodyaschegodokumenta", label: "Номер вх. документа" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALL_INCOME_COLUMNS: { key: string; label: string; type: "date" | "number" | "text" }[] = [
|
||||||
|
{ key: "data", label: "Дата", type: "date" },
|
||||||
|
{ key: "summa", label: "Сумма", type: "number" },
|
||||||
|
{ key: "statya", label: "Статья", type: "text" },
|
||||||
|
{ key: "zakazchik", label: "Заказчик", type: "text" },
|
||||||
|
{ key: "dochka", label: "Дочка", type: "text" },
|
||||||
|
{ key: "vnuchka", label: "Внучка", type: "text" },
|
||||||
|
{ key: "otvetstvenniipokontraktu", label: "Ответственный", type: "text" },
|
||||||
|
{ key: "stavkands", label: "Ставка НДС", type: "text" },
|
||||||
|
{ key: "kommentarii", label: "Комментарий", type: "text" },
|
||||||
|
{ key: "dogovorkontragenta", label: "Договор контрагента", type: "text" },
|
||||||
|
{ key: "schetnaoplatu", label: "Счет на оплату", type: "text" },
|
||||||
|
{ key: "naznachenieplatezha", label: "Назначение платежа", type: "text" },
|
||||||
|
{ key: "dogovorzakazchika", label: "Договор заказчика", type: "text" },
|
||||||
|
{ key: "statyadop", label: "Статья (доп.)", type: "text" },
|
||||||
|
{ key: "nomervhodyaschegodokumenta", label: "Номер вх. документа", type: "text" },
|
||||||
|
{ key: "id", label: "ID", type: "text" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_VISIBLE_COLUMNS = ["data", "summa", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii"];
|
||||||
|
|
||||||
|
export const ALL_EXPENSE_COLUMNS: { key: string; label: string; type: "date" | "number" | "text" }[] = [
|
||||||
|
{ key: "data", label: "Дата", type: "date" },
|
||||||
|
{ key: "summadokumenta", label: "Сумма документа", type: "number" },
|
||||||
|
{ key: "statya", label: "Статья", type: "text" },
|
||||||
|
{ key: "zakazchik", label: "Заказчик", type: "text" },
|
||||||
|
{ key: "dochka", label: "Дочка", type: "text" },
|
||||||
|
{ key: "vnuchka", label: "Внучка", type: "text" },
|
||||||
|
{ key: "kommentarii", label: "Комментарий", type: "text" },
|
||||||
|
{ key: "stavkands", label: "Ставка НДС", type: "text" },
|
||||||
|
{ key: "naznachenieplatezha", label: "Назначение платежа", type: "text" },
|
||||||
|
{ key: "otvetstvenniipokontraktu", label: "Ответственный", type: "text" },
|
||||||
|
{ key: "dogovorkontragenta", label: "Договор контрагента", type: "text" },
|
||||||
|
{ key: "dogovorzakazchika", label: "Договор заказчика", type: "text" },
|
||||||
|
{ key: "statyadop", label: "Статья (доп.)", type: "text" },
|
||||||
|
{ key: "ssilkanomervhodyaschegodokumenta", label: "Номер вх. документа", type: "text" },
|
||||||
|
{ key: "kontragent", label: "Контрагент", type: "text" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXPENSE_FILTERABLE_COLUMNS = [
|
||||||
|
{ key: "data", label: "Дата" },
|
||||||
|
{ key: "summadokumenta", label: "Сумма документа" },
|
||||||
|
{ key: "statya", label: "Статья" },
|
||||||
|
{ key: "zakazchik", label: "Заказчик" },
|
||||||
|
{ key: "dochka", label: "Дочка" },
|
||||||
|
{ key: "vnuchka", label: "Внучка" },
|
||||||
|
{ key: "kommentarii", label: "Комментарий" },
|
||||||
|
{ key: "stavkands", label: "Ставка НДС" },
|
||||||
|
{ key: "naznachenieplatezha", label: "Назначение платежа" },
|
||||||
|
{ key: "otvetstvenniipokontraktu", label: "Ответственный" },
|
||||||
|
{ key: "dogovorkontragenta", label: "Договор контрагента" },
|
||||||
|
{ key: "dogovorzakazchika", label: "Договор заказчика" },
|
||||||
|
{ key: "statyadop", label: "Статья (доп.)" },
|
||||||
|
{ key: "ssilkanomervhodyaschegodokumenta", label: "Номер вх. документа" },
|
||||||
|
{ key: "kontragent", label: "Контрагент" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_VISIBLE_EXPENSE_COLUMNS = ["data", "summadokumenta", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii"];
|
||||||
|
|
||||||
|
export const CATEGORY_RULE_COLUMNS = [
|
||||||
|
...INCOME_FILTERABLE_COLUMNS.map(c => ({ ...c, type: "text" as const })),
|
||||||
|
{ key: "summa", label: "Сумма", type: "number" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXPENSE_CATEGORY_RULE_COLUMNS = [
|
||||||
|
...EXPENSE_FILTERABLE_COLUMNS.filter(c => c.key !== "summadokumenta").map(c => ({ ...c, type: "text" as const })),
|
||||||
|
{ key: "summadokumenta", label: "Сумма документа", type: "number" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ExpenseCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rules: IncomeCategoryRule[];
|
||||||
|
ruleLogic?: "and" | "or";
|
||||||
|
excluded?: boolean;
|
||||||
|
children?: ExpenseCategory[];
|
||||||
|
autoDetected?: boolean;
|
||||||
|
treeId?: string;
|
||||||
|
}
|
||||||
154
server/finance-di2/table-config.ts
Normal file
154
server/finance-di2/table-config.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import type { TableConfig } from "./schema";
|
||||||
|
|
||||||
|
const TABLE_CONFIG_FILE = path.join(process.cwd(), "data", "table-config.json");
|
||||||
|
|
||||||
|
const SYSTEM_COLUMNS = ["id", "dataobnovleniyazapisi"];
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_LABELS: Record<string, string> = {
|
||||||
|
data: "Дата",
|
||||||
|
summa: "Сумма",
|
||||||
|
summadokumenta: "Сумма документа",
|
||||||
|
statya: "Статья",
|
||||||
|
zakazchik: "Заказчик",
|
||||||
|
dochka: "Дочка",
|
||||||
|
vnuchka: "Внучка",
|
||||||
|
kommentarii: "Комментарий",
|
||||||
|
stavkands: "Ставка НДС",
|
||||||
|
naznachenieplatezha: "Назначение платежа",
|
||||||
|
otvetstvenniipokontraktu: "Ответственный",
|
||||||
|
dogovorkontragenta: "Договор контрагента",
|
||||||
|
dogovorzakazchika: "Договор заказчика",
|
||||||
|
statyadop: "Статья (доп.)",
|
||||||
|
schetnaoplatu: "Счет на оплату",
|
||||||
|
nomervhodyaschegodokumenta: "Номер вх. документа",
|
||||||
|
ssilkanomervhodyaschegodokumenta: "Номер вх. документа",
|
||||||
|
kontragent: "Контрагент",
|
||||||
|
dogovor: "Договор",
|
||||||
|
summadolga: "Сумма долга",
|
||||||
|
summaprosrocheno: "Сумма просрочено",
|
||||||
|
period: "Период",
|
||||||
|
klassprosrochki: "Класс просрочки",
|
||||||
|
otvetstvennii: "Ответственный",
|
||||||
|
organizaciya: "Организация",
|
||||||
|
dokumentraschetov: "Документ расчётов",
|
||||||
|
summaoplacheno: "Сумма оплачено",
|
||||||
|
dneiprosrocheno: "Дней просрочено",
|
||||||
|
dneidolga: "Дней долга",
|
||||||
|
dataobrazovaniyadolga: "Дата образования долга",
|
||||||
|
planovayadatapogasheniya: "Плановая дата погашения",
|
||||||
|
otsrochkapodogovoru: "Отсрочка по договору",
|
||||||
|
kontragentinn: "ИНН контрагента",
|
||||||
|
valyuta: "Валюта",
|
||||||
|
summadolgaval: "Сумма долга (вал.)",
|
||||||
|
schet: "Счёт",
|
||||||
|
klassdolga: "Класс долга",
|
||||||
|
dataobnovleniyazapisi: "Дата обновления",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TABLE_CONFIG: TableConfig = {
|
||||||
|
incomeTable: "opdohodi",
|
||||||
|
expenseTable: "oprashodi",
|
||||||
|
debtTable: "vw_debt_active",
|
||||||
|
incomeSumColumn: "summa",
|
||||||
|
expenseSumColumn: "summadokumenta",
|
||||||
|
incomeColumns: ["id", "data", "summa", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands", "naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta", "schetnaoplatu", "dogovorzakazchika", "statyadop", "nomervhodyaschegodokumenta", "dataobnovleniyazapisi"],
|
||||||
|
expenseColumns: ["id", "data", "summadokumenta", "statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands", "naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta", "dogovorzakazchika", "statyadop", "ssilkanomervhodyaschegodokumenta", "kontragent", "dataobnovleniyazapisi"],
|
||||||
|
columnLabels: { ...DEFAULT_COLUMN_LABELS },
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedConfig: TableConfig | null = null;
|
||||||
|
|
||||||
|
export function loadTableConfig(): TableConfig {
|
||||||
|
if (cachedConfig) return cachedConfig;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(TABLE_CONFIG_FILE, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
cachedConfig = { ...DEFAULT_TABLE_CONFIG, ...parsed, columnLabels: { ...DEFAULT_COLUMN_LABELS, ...(parsed.columnLabels || {}) } };
|
||||||
|
return cachedConfig!;
|
||||||
|
} catch {
|
||||||
|
cachedConfig = { ...DEFAULT_TABLE_CONFIG };
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTableConfig(cfg: Partial<TableConfig>) {
|
||||||
|
const current = loadTableConfig();
|
||||||
|
const merged: TableConfig = {
|
||||||
|
...current,
|
||||||
|
...cfg,
|
||||||
|
columnLabels: { ...current.columnLabels, ...(cfg.columnLabels || {}) },
|
||||||
|
};
|
||||||
|
if (!fs.existsSync("data")) fs.mkdirSync("data", { recursive: true });
|
||||||
|
fs.writeFileSync(TABLE_CONFIG_FILE, JSON.stringify(merged, null, 2), "utf-8");
|
||||||
|
cachedConfig = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncomeTable(): string {
|
||||||
|
return "vw_income_effective";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpenseTable(): string {
|
||||||
|
return "vw_expense_effective";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncomeBaseTable(): string {
|
||||||
|
return loadTableConfig().incomeTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpenseBaseTable(): string {
|
||||||
|
return loadTableConfig().expenseTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncomeEffectiveView(): string {
|
||||||
|
return "vw_income_effective";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpenseEffectiveView(): string {
|
||||||
|
return "vw_expense_effective";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDebtTable(): string {
|
||||||
|
return loadTableConfig().debtTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncomeSumColumn(): string {
|
||||||
|
return loadTableConfig().incomeSumColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpenseSumColumn(): string {
|
||||||
|
return loadTableConfig().expenseSumColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedIncomeColumns(): string[] {
|
||||||
|
const cfg = loadTableConfig();
|
||||||
|
return cfg.incomeColumns.filter(c => !SYSTEM_COLUMNS.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllowedExpenseColumns(): string[] {
|
||||||
|
const cfg = loadTableConfig();
|
||||||
|
return cfg.expenseColumns.filter(c => !SYSTEM_COLUMNS.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoDetectAllowedColumns(type: "income" | "expense"): string[] {
|
||||||
|
const allowed = type === "income" ? getAllowedIncomeColumns() : getAllowedExpenseColumns();
|
||||||
|
return allowed.filter(c => c !== "data" && c !== "id" && c !== "summa" && c !== "summadokumenta" && c !== "dataobnovleniyazapisi");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnLabel(col: string): string {
|
||||||
|
const cfg = loadTableConfig();
|
||||||
|
return cfg.columnLabels[col] || col;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllIncomeColumns(): string[] {
|
||||||
|
return loadTableConfig().incomeColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllExpenseColumns(): string[] {
|
||||||
|
return loadTableConfig().expenseColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateTableConfigCache() {
|
||||||
|
cachedConfig = null;
|
||||||
|
}
|
||||||
@@ -125,3 +125,15 @@ export async function isConnected(): Promise<boolean> {
|
|||||||
return false;
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1057,6 +1057,14 @@ async function runStartupDataPatches() {
|
|||||||
// Static files for VPN client apps (olcbox APK/IPA)
|
// Static files for VPN client apps (olcbox APK/IPA)
|
||||||
app.use('/apps', express.static('/app/apps'));
|
app.use('/apps', express.static('/app/apps'));
|
||||||
|
|
||||||
|
// Data-Insight2 sub-app (финансы v2) — сборка кладётся в dist/public-di2 (Task 2).
|
||||||
|
// Блок стоит ПЕРЕД setupVite/serveStatic, чтобы и в dev, и в prod /di2/* не уходил в main index.html.
|
||||||
|
const di2Dist = path.resolve(import.meta.dirname, "public-di2");
|
||||||
|
if (fs.existsSync(di2Dist)) {
|
||||||
|
app.use("/di2", express.static(di2Dist));
|
||||||
|
app.use("/di2", (_req, res) => res.sendFile(path.resolve(di2Dist, "index.html")));
|
||||||
|
}
|
||||||
|
|
||||||
// Seed application roles and permissions
|
// Seed application roles and permissions
|
||||||
try {
|
try {
|
||||||
await storage.seedAppRolesAndPermissions();
|
await storage.seedAppRolesAndPermissions();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { registerPushRoutes } from "./push.routes";
|
|||||||
import { registerMcpRagRoutes } from "./mcp-rag.routes";
|
import { registerMcpRagRoutes } from "./mcp-rag.routes";
|
||||||
import { registerFieldTemplateInsertRoutes } from "./field-template-insert.routes";
|
import { registerFieldTemplateInsertRoutes } from "./field-template-insert.routes";
|
||||||
import { registerFinanceRoutes } from "../finance/routes";
|
import { registerFinanceRoutes } from "../finance/routes";
|
||||||
|
import { registerDi2Routes } from "../finance-di2";
|
||||||
import { registerDocumentRoutes } from "../documents/routes";
|
import { registerDocumentRoutes } from "../documents/routes";
|
||||||
import { registerDashboardRoutes } from "./dashboard.routes";
|
import { registerDashboardRoutes } from "./dashboard.routes";
|
||||||
import formAccessRouter from "./form-access.routes";
|
import formAccessRouter from "./form-access.routes";
|
||||||
@@ -110,6 +111,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
registerPushRoutes(app);
|
registerPushRoutes(app);
|
||||||
registerMcpRagRoutes(app);
|
registerMcpRagRoutes(app);
|
||||||
registerFieldTemplateInsertRoutes(app);
|
registerFieldTemplateInsertRoutes(app);
|
||||||
|
// Data-Insight2 (финансы v2) — СТРОГО перед старым модулем финансов: при совпадении путей приоритет у DI2
|
||||||
|
await registerDi2Routes(app);
|
||||||
await registerFinanceRoutes(app);
|
await registerFinanceRoutes(app);
|
||||||
await registerDocumentRoutes(app);
|
await registerDocumentRoutes(app);
|
||||||
registerDashboardRoutes(app);
|
registerDashboardRoutes(app);
|
||||||
|
|||||||
Reference in New Issue
Block a user