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:
2026-09-06 19:27:43 +03:00
parent a60a75eda0
commit 254950f151
16 changed files with 8200 additions and 0 deletions

View 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) — копируется с заменой путей алиасов.

View 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.

View 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 вместе с клиентом.

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -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");
}

View File

@@ -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();

View File

@@ -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);