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