From 7a8db03163483111238153aa866c46155204a1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Sat, 5 Sep 2026 15:07:36 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D1=8B:=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B1=D0=B8=D1=82=D0=BE=D1=80=D0=BA=D0=B0=20(vw=5F?= =?UTF-8?q?debt=5Factive),=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=D1=8B=20?= =?UTF-8?q?=D0=9D=D0=94=D0=A1/=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B1=D1=8B=D0=BB=D1=8C,=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D1=86=D0=B5=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D1=81=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D1=89=D0=B8=D0=BA=D0=BE=D0=BC,=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=80=D0=BF=D0=BB=D0=B0=D1=82=20=D0=B8=D0=B7=20Google=20?= =?UTF-8?q?Sheets,=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20Data-Insight?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 9 + client/src/components/finance/FinanceNav.tsx | 11 +- client/src/hooks/useFinance.ts | 449 +++++++ client/src/pages/finance/audit.tsx | 278 +++++ client/src/pages/finance/calculations.tsx | 1171 ++++++++++++++++++ client/src/pages/finance/debt.tsx | 651 ++++++++++ client/src/pages/salary.tsx | 231 ++++ package-lock.json | 305 +++++ package.json | 1 + server/finance/audit.ts | 566 +++++++++ server/finance/calculations.ts | 272 ++++ server/finance/category-matching.ts | 36 + server/finance/db-client.ts | 6 + server/finance/debt.ts | 318 +++++ server/finance/google-sheets.ts | 494 ++++++++ server/finance/routes.ts | 43 +- server/finance/table-config.ts | 2 + 17 files changed, 4809 insertions(+), 34 deletions(-) create mode 100644 client/src/pages/finance/audit.tsx create mode 100644 client/src/pages/finance/calculations.tsx create mode 100644 client/src/pages/finance/debt.tsx create mode 100644 server/finance/audit.ts create mode 100644 server/finance/calculations.ts create mode 100644 server/finance/category-matching.ts create mode 100644 server/finance/debt.ts create mode 100644 server/finance/google-sheets.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 420492c..0ff3eae 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -67,6 +67,9 @@ const IncomeCategories = safeLazy(() => import("@/pages/finance/income-categorie const ExpenseCategories = safeLazy(() => import("@/pages/finance/expense-categories")); const FinanceSettingsPage = safeLazy(() => import("@/pages/finance/settings")); +const DebtPage = safeLazy(() => import("@/pages/finance/debt")); +const CalculationsPage = safeLazy(() => import("@/pages/finance/calculations")); +const AuditPage = safeLazy(() => import("@/pages/finance/audit")); const DocumentsPage = safeLazy(() => import("@/pages/documents/DocumentsPage").then(m => ({ default: m.DocumentsPage }))); const TemplateEditorPage = safeLazy(() => import("@/pages/documents/TemplateEditorPage").then(m => ({ default: m.TemplateEditorPage }))); const GenerationHistoryPage = safeLazy(() => import("@/pages/documents/GenerationHistoryPage").then(m => ({ default: m.GenerationHistoryPage }))); @@ -111,6 +114,9 @@ const ROUTE_TITLES: Array<[RegExp, string]> = [ [/^\/settings/, 'Настройки'], [/^\/profile/, 'Профиль'], [/^\/billing/, 'Подписка и биллинг'], + [/^\/finance\/debt/, 'Дебиторка'], + [/^\/finance\/calculations/, 'Расчёты'], + [/^\/finance\/audit/, 'Аудит'], [/^\/finance/, 'Финансы'], [/^\/documents/, 'Документы'], [/^\/reports/, 'Отчёты'], @@ -179,6 +185,9 @@ function Router() { return ( + + + diff --git a/client/src/components/finance/FinanceNav.tsx b/client/src/components/finance/FinanceNav.tsx index 95c2917..a687fce 100644 --- a/client/src/components/finance/FinanceNav.tsx +++ b/client/src/components/finance/FinanceNav.tsx @@ -3,6 +3,9 @@ import { cn } from "@/lib/utils"; import { LayoutDashboard, Tags, + Wallet, + Calculator, + ShieldCheck, } from "lucide-react"; export type FinanceTab = @@ -12,7 +15,10 @@ export type FinanceTab = | "income-categories" | "expense-categories" | "settings" - | "salary"; + | "salary" + | "debt" + | "calculations" + | "audit"; interface FinanceNavProps { active: FinanceTab; @@ -21,6 +27,9 @@ interface FinanceNavProps { const items: { key: FinanceTab; label: string; icon: React.ReactNode; href?: string }[] = [ { key: "dashboard", label: "Обзор", icon: }, + { key: "debt", label: "Дебиторка", icon: , href: "/finance/debt" }, + { key: "calculations", label: "Расчёты", icon: , href: "/finance/calculations" }, + { key: "audit", label: "Аудит", icon: , href: "/finance/audit" }, { key: "income-categories", label: "Категории доходов", icon: }, { key: "expense-categories", label: "Категории расходов", icon: }, ]; diff --git a/client/src/hooks/useFinance.ts b/client/src/hooks/useFinance.ts index d18a531..a40da96 100644 --- a/client/src/hooks/useFinance.ts +++ b/client/src/hooks/useFinance.ts @@ -999,3 +999,452 @@ export function useContractSyncApply() { }, }); } + +// ===================== +// Debt (Дебиторская задолженность) +// ===================== + +export interface DebtSummary { + updatedAt: string | null; + totalDebt: number; + totalOverdue: number; +} + +export interface DebtClassRow { + name: string; + total: number; + cls0: number; + cls1_30: number; + cls31_60: number; + cls61_90: number; + cls91_365: number; + cls365plus: number; +} + +export interface DebtOverdueClassRow { + cls: string; + total: number; +} + +export interface DebtDetailRow { + dogovor: string; + summadokumenta: number; + dokumentraschetov: string; + summaoplacheno: number; + summadolga: number; + summaprosrocheno: number; + otsrochkapodogovoru: string; + dneiprosrocheno: number; +} + +export interface DebtTableGroup { + kontragent: string; + totalDebt: number; + totalOverdue: number; + rows: DebtDetailRow[]; +} + +export interface DebtAnalytics { + success: boolean; + summary: DebtSummary; + byKontragent: DebtClassRow[]; + byManager: DebtClassRow[]; + byOverdueClass: DebtOverdueClassRow[]; + table: { + rows: DebtTableGroup[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; +} + +export interface DebtFiltersResponse { + success: boolean; + filters: { + periods: string[]; + klassprosrochki: string[]; + organizaciya: string[]; + otvetstvennii: string[]; + kontragenty: string[]; + }; +} + +export interface DebtDynamicsResponse { + success: boolean; + rows: { period: string; totalDebt: number; totalOverdue: number }[]; +} + +export interface DebtDynamicsKontragent { + kontragent: string; + summadokumenta: number; + summaoplacheno: number; + summadolga: number; + summaprosrocheno: number; + dneiprosrocheno: number; +} + +export interface DebtAnalyticsParams { + period?: string; + klassprosrochki?: string[]; + kontragent?: string[]; + dogovor?: string; + otvetstvennii?: string[]; + page?: number; + pageSize?: number; +} + +export function useDebtAnalytics(params: DebtAnalyticsParams) { + return useQuery({ + queryKey: ["/api/debt/analytics", JSON.stringify(params)], + queryFn: async () => { + const sp = new URLSearchParams(); + if (params.period) sp.set("period", params.period); + if (params.klassprosrochki?.length) sp.set("klassprosrochki", params.klassprosrochki.join(",")); + if (params.kontragent?.length) sp.set("kontragent", params.kontragent.join(",")); + if (params.dogovor) sp.set("dogovor", params.dogovor); + if (params.otvetstvennii?.length) sp.set("otvetstvennii", params.otvetstvennii.join(",")); + sp.set("page", String(params.page ?? 1)); + sp.set("pageSize", String(params.pageSize ?? 20)); + const res = await apiRequest("GET", `/api/debt/analytics?${sp.toString()}`); + return res.json(); + }, + }); +} + +export function useDebtFilters() { + return useQuery({ + queryKey: ["/api/debt/filters"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/debt/filters"); + return res.json(); + }, + }); +} + +export function useDebtDynamics(dateFrom?: string, dateTo?: string, kontragent?: string) { + return useQuery({ + queryKey: ["/api/debt/dynamics", dateFrom, dateTo, kontragent], + queryFn: async () => { + const sp = new URLSearchParams(); + if (dateFrom) sp.set("dateFrom", dateFrom); + if (dateTo) sp.set("dateTo", dateTo); + if (kontragent) sp.set("kontragent", kontragent); + const res = await apiRequest("GET", `/api/debt/dynamics?${sp.toString()}`); + return res.json(); + }, + }); +} + +export function useDebtKontragenty(period?: string, dateTo?: string) { + return useQuery<{ success: boolean; rows: DebtDynamicsKontragent[] }>({ + queryKey: ["/api/debt/dynamics/kontragenty", period, dateTo], + queryFn: async () => { + const sp = new URLSearchParams(); + if (period) sp.set("period", period); + if (dateTo) sp.set("dateTo", dateTo); + const qs = sp.toString(); + const res = await apiRequest("GET", `/api/debt/dynamics/kontragenty${qs ? `?${qs}` : ""}`); + return res.json(); + }, + }); +} + +// ===================== +// Calculations (НДС и налог на прибыль) +// ===================== + +export interface VatRateRow { + rate: string; + count: number; + total: number; + vat: number; +} + +export interface VatResponse { + success: boolean; + incomeByRate: VatRateRow[]; + expenseByRate: VatRateRow[]; + totalIncomeVat: number; + totalExpenseVat: number; + toPay: number; + monthly: { period: string; incomeVat: number; expenseVat: number; toPay: number }[]; +} + +export function useVat(dateFrom?: string, dateTo?: string) { + return useQuery({ + queryKey: ["/api/calculations/vat", dateFrom, dateTo], + queryFn: async () => { + const sp = new URLSearchParams(); + if (dateFrom) sp.set("dateFrom", dateFrom); + if (dateTo) sp.set("dateTo", dateTo); + const res = await apiRequest("GET", `/api/calculations/vat?${sp.toString()}`); + return res.json(); + }, + }); +} + +export interface ChartCategoryRef { + id: string; + type: "income" | "expense"; + name?: string; +} + +export interface ProfitTaxParams { + dateFrom?: string; + dateTo?: string; + taxRate: number; + incomeCategories?: string[]; + expenseCategories?: string[]; + chartCategories?: ChartCategoryRef[]; +} + +export interface ProfitTaxResponse { + success: boolean; + income: number; + expenses: number; + profit: number; + taxRate: number; + tax: number; + monthly: { period: string; income: number; expenses: number; profit: number; tax: number }[]; + incomeWithVatBase: number; + expenseWithVatBase: number; + incomeNoVat: number; + expenseNoVat: number; + excludedIncomeCategories: { name: string; total: number }[]; + excludedExpenseCategories: { name: string; total: number }[]; + categoryMonthly?: { id: string; name: string; type: string; data: { period: string; amount: number }[] }[]; +} + +export function useProfitTax() { + return useMutation({ + mutationFn: async (params) => { + const sp = new URLSearchParams(); + if (params.dateFrom) sp.set("dateFrom", params.dateFrom); + if (params.dateTo) sp.set("dateTo", params.dateTo); + sp.set("taxRate", String(params.taxRate)); + const body: Record = {}; + if (params.incomeCategories?.length) body.incomeCategories = params.incomeCategories; + if (params.expenseCategories?.length) body.expenseCategories = params.expenseCategories; + if (params.chartCategories?.length) body.chartCategories = params.chartCategories; + const res = await apiRequest("POST", `/api/calculations/profit-tax?${sp.toString()}`, body); + return res.json(); + }, + }); +} + +// ===================== +// Audit (проверка распределения по категориям) +// ===================== + +export interface AuditGapRow { + columns: Record; + sum: number; + count: number; +} + +export interface DirectionAudit { + dbTotal: number; + dbCount: number; + treeTotal: number; + treeCount: number; + excludedTotal: number; + excludedCount: number; + uncategorizedTotal: number; + uncategorizedCount: number; + gapTotal: number; + gapCount: number; + gapRows: AuditGapRow[]; + treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[]; +} + +export interface AuditResult { + timestamp: string; + status: "ok" | "warning" | "error"; + summary: string; + income: DirectionAudit; + expense: DirectionAudit; +} + +export function useAuditHistory() { + return useQuery<{ success: boolean; history: AuditResult[] }>({ + queryKey: ["/api/audit/history"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/audit/history"); + return res.json(); + }, + }); +} + +export function useAuditStatus() { + return useQuery<{ success: boolean; last: AuditResult | null; running: boolean }>({ + queryKey: ["/api/audit/status"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/audit/status"); + return res.json(); + }, + refetchInterval: 10_000, + }); +} + +export function useRunAudit() { + const qc = useQueryClient(); + const { toast } = useToast(); + return useMutation<{ success: boolean; result: AuditResult }, Error>({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/audit/run"); + return res.json(); + }, + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ["/api/audit/history"] }); + qc.invalidateQueries({ queryKey: ["/api/audit/status"] }); + toast({ + title: "Аудит завершён", + description: data?.result?.status === "ok" ? "Расхождений не найдено" : "Обнаружены расхождения", + variant: data?.result?.status === "ok" ? "default" : "destructive", + }); + }, + onError: (err: any) => { + toast({ title: "Ошибка аудита", description: err?.message, variant: "destructive" }); + }, + }); +} + +// ===================== +// Salary: Google Sheets source +// ===================== + +export interface GoogleStatus { + success: boolean; + loggedIn: boolean; + email?: string; +} + +export function useGoogleStatus() { + return useQuery({ + queryKey: ["/api/finance/google/status"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/finance/google/status"); + return res.json(); + }, + }); +} + +export function useGoogleAuthUrl() { + return useQuery<{ success: boolean; url: string }>({ + queryKey: ["/api/finance/google/auth-url"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/finance/google/auth-url"); + return res.json(); + }, + enabled: false, + retry: false, + }); +} + +export interface SalaryArea { + id: string; + name: string; + spreadsheetId?: string; + spreadsheetUrl?: string; + fioCol?: string; + bazaCol?: string; + kVydacheCol?: string; +} + +export function useSalaryAreas() { + return useQuery<{ success: boolean; areas: SalaryArea[] }>({ + queryKey: ["/api/salary/areas"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/salary/areas"); + return res.json(); + }, + }); +} + +export function useCreateSalaryArea() { + const qc = useQueryClient(); + const { toast } = useToast(); + return useMutation({ + mutationFn: async (body: { name: string; spreadsheetUrl: string; fioCol?: string; bazaCol?: string; kVydacheCol?: string }) => { + const res = await apiRequest("POST", "/api/salary/areas", body); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["/api/salary/areas"] }); + qc.invalidateQueries({ queryKey: ["/api/salary/neof"] }); + toast({ title: "Участок добавлен" }); + }, + onError: (err: any) => { + toast({ title: "Ошибка", description: err?.message, variant: "destructive" }); + }, + }); +} + +export function useUpdateSalaryArea() { + const qc = useQueryClient(); + const { toast } = useToast(); + return useMutation({ + mutationFn: async ({ id, ...body }: { id: string; fioCol?: string; bazaCol?: string; kVydacheCol?: string }) => { + const res = await apiRequest("PATCH", `/api/salary/areas/${id}`, body); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["/api/salary/areas"] }); + qc.invalidateQueries({ queryKey: ["/api/salary/neof"] }); + toast({ title: "Столбцы обновлены" }); + }, + onError: (err: any) => { + toast({ title: "Ошибка", description: err?.message, variant: "destructive" }); + }, + }); +} + +export function useDeleteSalaryArea() { + const qc = useQueryClient(); + const { toast } = useToast(); + return useMutation({ + mutationFn: async (id: string) => { + const res = await apiRequest("DELETE", `/api/salary/areas/${id}`); + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["/api/salary/areas"] }); + qc.invalidateQueries({ queryKey: ["/api/salary/neof"] }); + toast({ title: "Участок удалён" }); + }, + onError: (err: any) => { + toast({ title: "Ошибка", description: err?.message, variant: "destructive" }); + }, + }); +} + +export interface SalarySyncResult { + success: boolean; + added: number; + updated: number; + deleted: number; + total: number; + errors?: string[]; + authRequired?: boolean; +} + +export function useSalarySync() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => { + try { + const res = await apiRequest("POST", "/api/salary/sync"); + return res.json(); + } catch (err: any) { + // 401 с authRequired — не ошибка, а сигнал «нужна авторизация Google» + if (err?.status === 401) return { success: false, authRequired: true } as SalarySyncResult; + throw err; + } + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["/api/salary/neof"] }); + qc.invalidateQueries({ queryKey: ["/api/salary/db-status"] }); + }, + }); +} diff --git a/client/src/pages/finance/audit.tsx b/client/src/pages/finance/audit.tsx new file mode 100644 index 0000000..15e315e --- /dev/null +++ b/client/src/pages/finance/audit.tsx @@ -0,0 +1,278 @@ +import { useState } from "react"; +import { FinanceLayout } from "@/components/finance/FinanceLayout"; +import { FinanceNav } from "@/components/finance/FinanceNav"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Loader2, Play, CheckCircle2, AlertTriangle, Clock, RefreshCw, ChevronDown, ChevronRight } from "lucide-react"; +import { + useAuditHistory, + useAuditStatus, + useRunAudit, + type AuditResult, + type DirectionAudit, +} from "@/hooks/useFinance"; + +function formatMoney(v: number): string { + return v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽"; +} + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleString("ru-RU", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} + +function StatusBadge({ status }: { status: string }) { + if (status === "ok") return Всё в порядке; + if (status === "warning") return Есть расхождения; + return Ошибка; +} + +const MAX_GAP_ROWS = 50; + +function DirectionCard({ title, data }: { title: string; data: DirectionAudit }) { + const pctTree = data.dbCount > 0 ? ((data.treeCount / data.dbCount) * 100).toFixed(1) : "0"; + const pctExcl = data.dbCount > 0 ? ((data.excludedCount / data.dbCount) * 100).toFixed(1) : "0"; + const pctUncat = data.dbCount > 0 ? ((data.uncategorizedCount / data.dbCount) * 100).toFixed(1) : "0"; + + return ( + + +
+ {title} + {data.gapCount === 0 + ? Gap: 0 + : Gap: {data.gapCount} строк + } +
+
+ + + + + Категория + Строк + % + Сумма + + + + + Всего в БД + {data.dbCount} + 100% + {formatMoney(data.dbTotal)} + + + В деревьях (не исключённые) + {data.treeCount} + {pctTree}% + {formatMoney(data.treeTotal)} + + + Исключённые категории + {data.excludedCount} + {pctExcl}% + {formatMoney(data.excludedTotal)} + + + Нераспределённые + {data.uncategorizedCount} + {pctUncat}% + {formatMoney(data.uncategorizedTotal)} + + + Gap (расхождение) + {data.gapCount} + — + {formatMoney(data.gapTotal)} + + +
+ + {data.treeBreakdown.length > 0 && ( +
+

Деревья

+ + + + Дерево + Строк + Сумма + Primary + + + + {data.treeBreakdown.map((t, i) => ( + + {t.name} + {t.count} + {formatMoney(t.total)} + + {t.isPrimary ? : —} + + + ))} + +
+
+ )} + + {data.gapRows.length > 0 && ( +
+

Нераспределённые строки (gap)

+
+ + + + {Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => ( + {col} + ))} + Сумма + Кол-во + + + + {data.gapRows.slice(0, MAX_GAP_ROWS).map((r, i) => ( + + {Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => ( + {r.columns[col] || "—"} + ))} + {formatMoney(r.sum)} + {r.count} + + ))} + +
+
+ {data.gapRows.length > MAX_GAP_ROWS && ( +

+ Показаны первые {MAX_GAP_ROWS} из {data.gapRows.length} строк +

+ )} +
+ )} +
+
+ ); +} + +function AuditEntry({ audit, defaultOpen }: { audit: AuditResult; defaultOpen: boolean }) { + const [open, setOpen] = useState(defaultOpen); + + return ( + + + {open && ( + +
+ + +
+
+ )} +
+ ); +} + +export default function AuditPage() { + const { data: historyData, isLoading: historyLoading } = useAuditHistory(); + const { data: statusData } = useAuditStatus(); + const runAudit = useRunAudit(); + + const history = historyData?.history ?? []; + const latestAudit = statusData?.last ?? history[0] ?? null; + const running = statusData?.running || runAudit.isPending; + + return ( + +
+
+
+

Аудит данных

+

+ Проверка целостности распределения строк по категориям +

+
+
+ {latestAudit && } + +
+
+ + {}} /> + + {historyLoading && ( +
+ + Загрузка... +
+ )} + + {latestAudit && ( + + +
+ + + Последний аудит: {formatDateTime(latestAudit.timestamp)} + + {latestAudit.summary} +
+
+
+ )} + + {!historyLoading && history.length === 0 && !latestAudit && ( + + + +

Аудит ещё не проводился

+

Нажмите «Запустить аудит» для первой проверки

+
+
+ )} + + {history.length > 0 && ( +
+ {history.map((h, i) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/finance/calculations.tsx b/client/src/pages/finance/calculations.tsx new file mode 100644 index 0000000..3bb219c --- /dev/null +++ b/client/src/pages/finance/calculations.tsx @@ -0,0 +1,1171 @@ +import { useState, useMemo, useEffect } from "react"; +import { FinanceLayout } from "@/components/finance/FinanceLayout"; +import { FinanceNav } from "@/components/finance/FinanceNav"; +import { DatabaseError } from "@/components/finance/DatabaseError"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { CalendarDays, X, TrendingUp, TrendingDown, DollarSign, Receipt, Calculator, Settings, ChevronRight, ChevronDown, Search } from "lucide-react"; +import { + useVat, + useProfitTax, + useIncomeCategories, + useExpenseCategories, + type Category, + type VatResponse, + type ProfitTaxResponse, + type ChartCategoryRef, +} from "@/hooks/useFinance"; +import { formatCurrency } from "@/lib/format"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Line, ComposedChart } from "recharts"; + +function flattenCatIds(cats: Category[]): string[] { + const ids: string[] = []; + for (const c of cats) { + ids.push(c.id); + if (c.children?.length) ids.push(...flattenCatIds(c.children)); + } + return ids; +} + +function getChildIds(node: Category): string[] { + const ids: string[] = []; + if (node.children?.length) { + for (const c of node.children) { + ids.push(c.id); + ids.push(...getChildIds(c)); + } + } + return ids; +} + +function countTopLevel(cats: Category[]): number { + return cats.filter(c => !c.excluded).length; +} + +function countExcluded(cats: Category[]): number { + let count = 0; + for (const c of cats) { + if (c.excluded) count++; + if (c.children?.length) count += countExcluded(c.children); + } + return count; +} + +function CategoryTree({ cats, selected, onChange, label }: { + cats: Category[]; + selected: Set; + onChange: (newSet: Set) => void; + label: string; +}) { + const [expanded, setExpanded] = useState>(new Set()); + const allIds = useMemo(() => flattenCatIds(cats), [cats]); + const allSelected = allIds.length > 0 && allIds.every(id => selected.has(id)); + const someSelected = allIds.length > 0 && allIds.some(id => selected.has(id)); + const excludedCount = useMemo(() => countExcluded(cats), [cats]); + + const toggleExpand = (id: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (allIds.length === 0) return; + if (allSelected) { + onChange(new Set()); + } else { + onChange(new Set(allIds)); + } + }; + + const toggleNode = (node: Category) => { + const next = new Set(selected); + const ids = [node.id, ...getChildIds(node)]; + if (next.has(node.id)) { + ids.forEach(id => next.delete(id)); + } else { + ids.forEach(id => next.add(id)); + } + onChange(next); + }; + + const renderNode = (node: Category, depth: number) => { + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expanded.has(node.id); + const isChecked = selected.has(node.id); + const childIds = hasChildren ? getChildIds(node) : []; + const someChildrenSelected = hasChildren && childIds.some(id => selected.has(id)); + const allChildrenSelected = hasChildren && childIds.length > 0 && childIds.every(id => selected.has(id)); + const isIndeterminate = hasChildren && someChildrenSelected && !(isChecked && allChildrenSelected); + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + toggleNode(node)} + className="h-3.5 w-3.5" + /> + { if (hasChildren) toggleExpand(node.id); }} + > + {node.name} + +
+ {hasChildren && isExpanded && node.children!.map(child => renderNode(child, depth + 1))} +
+ ); + }; + + return ( +
+
+ + +
+

+ Категории ({cats.filter(c => !c.excluded).length} верхних{excludedCount > 0 ? `, исключено: ${excludedCount}` : ""}) +

+ + {cats.map(c => renderNode(c, 0))} + +
+ ); +} + +const COLORS = { + income: "#2563eb", + expense: "#e11d48", + profit: "#16a34a", + tax: "#f59e0b", + toPay: "#8b5cf6", + cumulative: "#0d9488", +}; + +const HIGHLIGHT_COLORS = [ + "#f97316", "#8b5cf6", "#0d9488", "#ec4899", "#84cc16", + "#06b6d4", "#a855f7", "#eab308", "#14b8a6", "#f43f5e", +]; + +interface ChartHighlight { + id: string; + name: string; + type: "income" | "expense"; +} + +const MONTH_NAMES = [ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", +]; + +type GroupBy = "month" | "quarter" | "year"; + +function rateOrder(rate: string): number { + const lower = rate.toLowerCase().trim(); + if (lower === "без ндс") return -1; + const m = rate.match(/(\d+)/); + return m ? Number(m[1]) : 0; +} + +function sortByRate(arr: T[]): T[] { + return [...arr].sort((a, b) => rateOrder(a.rate) - rateOrder(b.rate)); +} + +function formatPeriodLabel(period: string, groupBy: GroupBy): string { + try { + if (groupBy === "year") return period; + if (groupBy === "quarter") return period; + const [y, m] = period.split("-"); + const d = new Date(Number(y), Number(m) - 1); + return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" }); + } catch { + return period; + } +} + +function tickFormatter(v: number) { + if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (Math.abs(v) >= 1000) return `${(v / 1_000).toFixed(0)}K`; + return String(v); +} + +function getQuarterKey(period: string, startMonth: number): string { + const [y, m] = period.split("-").map(Number); + const adjusted = ((m - startMonth + 12) % 12); + const q = Math.floor(adjusted / 3) + 1; + const qYear = m < startMonth ? y - 1 : y; + return `${qYear} Q${q}`; +} + +function getYearKey(period: string): string { + return period.split("-")[0]; +} + +function groupMonthlyData( + monthly: T[], + groupBy: GroupBy, + startMonth: number, + sumFields: (keyof T)[], +): (T & { period: string })[] { + if (groupBy === "month") return monthly; + + const groups = new Map(); + for (const row of monthly) { + const key = groupBy === "quarter" ? getQuarterKey(row.period, startMonth) : getYearKey(row.period); + if (!groups.has(key)) { + const init: any = { period: key }; + for (const f of sumFields) init[f] = 0; + groups.set(key, init); + } + const g = groups.get(key)!; + for (const f of sumFields) { + g[f] = Math.round(((g[f] as number) + (row[f] as number)) * 100) / 100; + } + } + return [...groups.values()]; +} + +function addCumulativeVat( + data: { period: string; incomeVat: number; expenseVat: number; toPay: number }[], + startMonth: number, +): (typeof data[number] & { cumToPay: number })[] { + let cumulative = 0; + let currentQuarter = ""; + return data.map(row => { + const q = getQuarterKey(row.period, startMonth); + if (q !== currentQuarter) { + cumulative = 0; + currentQuarter = q; + } + cumulative += row.toPay; + return { ...row, cumToPay: Math.round(cumulative * 100) / 100 }; + }); +} + +function VatTab({ data }: { data: VatResponse }) { + const [groupBy, setGroupBy] = useState( + () => (localStorage.getItem("omegaprom_vat_groupby") as GroupBy) || "month" + ); + const [startMonth, setStartMonth] = useState( + () => Number(localStorage.getItem("omegaprom_vat_start_month") || "1") + ); + + function changeGroupBy(v: GroupBy) { + setGroupBy(v); + localStorage.setItem("omegaprom_vat_groupby", v); + } + + function changeStartMonth(v: string) { + const m = Number(v); + setStartMonth(m); + localStorage.setItem("omegaprom_vat_start_month", String(m)); + } + + const chartData = useMemo(() => { + if (!data.monthly?.length) return []; + const grouped = groupMonthlyData( + data.monthly, + groupBy, + startMonth, + ["incomeVat", "expenseVat", "toPay"], + ); + if (groupBy === "month") { + return addCumulativeVat(grouped, startMonth); + } + return grouped.map((r: any) => ({ ...r, cumToPay: undefined })); + }, [data.monthly, groupBy, startMonth]); + + return ( +
+
+ + +
+
+ +
+ НДС с доходов +
+

{formatCurrency(data.totalIncomeVat)}

+
+
+ + +
+
+ +
+ НДС с расходов +
+

{formatCurrency(data.totalExpenseVat)}

+
+
+ + +
+
+ +
+ К уплате +
+

= 0 ? "text-rose-600" : "text-green-600"}`}> + {formatCurrency(data.toPay)} +

+
+
+
+ + {chartData.length > 0 && ( + + +
+ Динамика НДС +
+
+ Начало квартала: + +
+
+ {([["month", "Месяц"], ["quarter", "Квартал"], ["year", "Год"]] as const).map(([key, label]) => ( + + ))} +
+
+
+
+ + + + + formatPeriodLabel(v, groupBy)} + tick={{ fontSize: 11 }} + axisLine={false} + tickLine={false} + /> + + { + if (!active || !payload?.length) return null; + return ( +
+

{formatPeriodLabel(label, groupBy)}

+ {payload.map((entry: any) => ( +
+
+ {entry.name} + {formatCurrency(entry.value)} +
+ ))} +
+ ); + }} + /> + + + + + {groupBy === "month" && ( + + )} + + + {groupBy === "month" && ( +

+ Пунктирная линия — накопительный итог к уплате, сбрасывается в начале каждого квартала (с {MONTH_NAMES[startMonth - 1].toLowerCase()}) +

+ )} + + + )} + +
+ + + По ставкам (доходы) + + +
+ {sortByRate(data.incomeByRate ?? []).map((r) => ( +
+
+ {r.rate} + ({r.count} док.) +
+
+ {formatCurrency(r.total)} + + НДС: {formatCurrency(r.vat)} + +
+
+ ))} +
+
+
+ + + По ставкам (расходы) + + +
+ {sortByRate(data.expenseByRate ?? []).map((r) => ( +
+
+ {r.rate} + ({r.count} док.) +
+
+ {formatCurrency(r.total)} + + НДС: {formatCurrency(r.vat)} + +
+
+ ))} +
+
+
+
+
+ ); +} + +function FormulaDialog({ data, taxRate, onTaxRateChange, incCats, expCats, onIncCatsChange, onExpCatsChange, incomeCats, expenseCats }: { + data: ProfitTaxResponse; + taxRate: number; + onTaxRateChange: (v: number) => void; + incCats: Set; + expCats: Set; + onIncCatsChange: (ids: Set) => void; + onExpCatsChange: (ids: Set) => void; + incomeCats: Category[]; + expenseCats: Category[]; +}) { + const [open, setOpen] = useState(false); + const [localIncCats, setLocalIncCats] = useState>(new Set()); + const [localExpCats, setLocalExpCats] = useState>(new Set()); + const [localTaxRate, setLocalTaxRate] = useState(taxRate); + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + setLocalIncCats(new Set(incCats)); + setLocalExpCats(new Set(expCats)); + setLocalTaxRate(taxRate); + } + setOpen(isOpen); + }; + + const handleSave = () => { + onTaxRateChange(localTaxRate); + onIncCatsChange(localIncCats); + onExpCatsChange(localExpCats); + setOpen(false); + }; + + return ( + + + + + + + Формула расчёта налога на прибыль + Настройка ставки, категорий и просмотр формулы расчёта + +
+
+ +
+ { + const v = Number(e.target.value); + if (!isNaN(v) && v >= 0 && v <= 100) setLocalTaxRate(v); + }} + className="w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + % +
+
+ +
+ {incomeCats.length > 0 && ( + + )} + {expenseCats.length > 0 && ( + + )} +
+ {(localIncCats.size > 0 || localExpCats.size > 0) && ( +

+ Выбрано: {localIncCats.size > 0 ? `${localIncCats.size} кат. доходов` : "все доходы"},{" "} + {localExpCats.size > 0 ? `${localExpCats.size} кат. расходов` : "все расходы"} +

+ )} + +
+

Формула:

+

Прибыль = (Приходы с НДС - НДС) - (Расходы с НДС - НДС) + (Приходы без НДС - Расходы без НДС)

+

Налог = Прибыль x {localTaxRate}%

+

Налог начисляется только при положительной прибыли

+
+
+

Текущий расчёт:

+
+

С НДС: {formatCurrency(data.incomeWithVatBase ?? 0)} - {formatCurrency(data.expenseWithVatBase ?? 0)} = {formatCurrency((data.incomeWithVatBase ?? 0) - (data.expenseWithVatBase ?? 0))}

+

Без НДС: {formatCurrency(data.incomeNoVat ?? 0)} - {formatCurrency(data.expenseNoVat ?? 0)} = {formatCurrency((data.incomeNoVat ?? 0) - (data.expenseNoVat ?? 0))}

+

Прибыль: = 0 ? "text-green-600" : "text-rose-600"}`}>{formatCurrency(data.profit)}

+

Налог: {formatCurrency(Math.max(data.profit, 0))} x {taxRate}% = {formatCurrency(data.tax)}

+
+
+
+ + +
+
+
+
+ ); +} + +function flattenCatNodes(cats: Category[], parentName?: string): { id: string; name: string; fullPath: string }[] { + const result: { id: string; name: string; fullPath: string }[] = []; + for (const c of cats) { + if (c.excluded) continue; + const path = parentName ? `${parentName} / ${c.name}` : c.name; + result.push({ id: c.id, name: c.name, fullPath: path }); + if (c.children?.length) result.push(...flattenCatNodes(c.children, path)); + } + return result; +} + +function ChartCategoryDialog({ highlights, onChange, incomeCats, expenseCats }: { + highlights: ChartHighlight[]; + onChange: (h: ChartHighlight[]) => void; + incomeCats: Category[]; + expenseCats: Category[]; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const selectedKeys = useMemo(() => new Set(highlights.map(h => `${h.type}:${h.id}`)), [highlights]); + + const flatIncome = useMemo(() => flattenCatNodes(incomeCats), [incomeCats]); + const flatExpense = useMemo(() => flattenCatNodes(expenseCats), [expenseCats]); + + const filteredIncome = useMemo(() => { + if (!search.trim()) return flatIncome; + const q = search.toLowerCase(); + return flatIncome.filter(c => c.name.toLowerCase().includes(q) || c.fullPath.toLowerCase().includes(q)); + }, [flatIncome, search]); + + const filteredExpense = useMemo(() => { + if (!search.trim()) return flatExpense; + const q = search.toLowerCase(); + return flatExpense.filter(c => c.name.toLowerCase().includes(q) || c.fullPath.toLowerCase().includes(q)); + }, [flatExpense, search]); + + function toggle(cat: { id: string; name: string }, type: "income" | "expense") { + if (selectedKeys.has(`${type}:${cat.id}`)) { + onChange(highlights.filter(h => !(h.id === cat.id && h.type === type))); + } else { + onChange([...highlights, { id: cat.id, name: cat.name, type }]); + } + } + + return ( + { setOpen(v); if (!v) setSearch(""); }}> + + + + + + Категории на графике + Выберите категории для отображения отдельными столбиками + +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ {filteredIncome.length > 0 && ( +
+

+ Доходы +

+
+ {filteredIncome.map(c => ( + + ))} +
+
+ )} + {filteredExpense.length > 0 && ( +
+

+ Расходы +

+
+ {filteredExpense.map(c => ( + + ))} +
+
+ )} + {filteredIncome.length === 0 && filteredExpense.length === 0 && ( +

Ничего не найдено

+ )} +
+
+ {highlights.length > 0 && ( +
+ Выбрано: {highlights.length} + +
+ )} +
+
+ ); +} + +function ProfitTab({ data, taxRate, onTaxRateChange, incCats, expCats, onIncCatsChange, onExpCatsChange, chartHighlights, onChartHighlightsChange }: { + data: ProfitTaxResponse; + taxRate: number; + onTaxRateChange: (v: number) => void; + incCats: Set; + expCats: Set; + onIncCatsChange: (ids: Set) => void; + onExpCatsChange: (ids: Set) => void; + chartHighlights: ChartHighlight[]; + onChartHighlightsChange: (highlights: ChartHighlight[]) => void; +}) { + const [groupBy, setGroupBy] = useState( + () => (localStorage.getItem("omegaprom_pt_groupby") as GroupBy) || "month" + ); + + const { data: incomeCatsData } = useIncomeCategories(); + const { data: expenseCatsData } = useExpenseCategories(); + const incomeCats = useMemo(() => incomeCatsData?.categories ?? [], [incomeCatsData]); + const expenseCats = useMemo(() => expenseCatsData?.categories ?? [], [expenseCatsData]); + + function changeGroupBy(v: GroupBy) { + setGroupBy(v); + localStorage.setItem("omegaprom_pt_groupby", v); + } + + const chartData = useMemo(() => { + if (!data.monthly?.length) return []; + const base = groupMonthlyData( + data.monthly, + groupBy, + 1, + ["income", "expenses", "profit", "tax"], + ); + if (!chartHighlights.length || !data.categoryMonthly?.length) return base; + const catMonthly = data.categoryMonthly; + return base.map((row: any) => { + const newRow = { ...row }; + let incSub = 0; + let expSub = 0; + for (const cm of catMonthly) { + const key = `_cat_${cm.type}_${cm.id}`; + let catTotal = 0; + if (groupBy === "month") { + const match = cm.data.find((d: any) => d.period === row.period); + catTotal = match ? match.amount : 0; + } else { + for (const d of cm.data) { + const qKey = groupBy === "quarter" + ? getQuarterKey(d.period, 1) + : d.period.split("-")[0]; + if (qKey === row.period) catTotal += d.amount; + } + } + newRow[key] = Math.round(catTotal * 100) / 100; + if (cm.type === "income") incSub += catTotal; + else expSub += catTotal; + } + if (incSub > 0) newRow.income = Math.round((newRow.income - incSub) * 100) / 100; + if (expSub > 0) newRow.expenses = Math.round((newRow.expenses - expSub) * 100) / 100; + return newRow; + }); + }, [data.monthly, data.categoryMonthly, groupBy, chartHighlights]); + + return ( +
+
+ + +
+
+ +
+ Доходы +
+

{formatCurrency(data.income)}

+
+
+ + +
+
+ +
+ Расходы +
+

{formatCurrency(data.expenses)}

+
+
+ + +
+
+ +
+ Прибыль +
+

= 0 ? "text-green-600" : "text-rose-600"}`}> + {formatCurrency(data.profit)} +

+
+
+ + +
+
+ +
+ Налог +
+

{formatCurrency(data.tax)}

+
+
+ + + + + +
+ + {chartData.length > 0 && ( + + +
+
+ Динамика + +
+
+ {([["month", "Месяц"], ["quarter", "Квартал"], ["year", "Год"]] as const).map(([key, label]) => ( + + ))} +
+
+ {chartHighlights.length > 0 && ( +
+ {chartHighlights.map((h, i) => ( + + + {h.name} + + + ))} +
+ )} +
+ + + + + formatPeriodLabel(v, groupBy)} + tick={{ fontSize: 11 }} + axisLine={false} + tickLine={false} + /> + + { + if (!active || !payload?.length) return null; + return ( +
+

{formatPeriodLabel(label, groupBy)}

+ {payload.map((entry: any) => ( +
+
+ {entry.name} + {formatCurrency(entry.value)} +
+ ))} +
+ ); + }} + /> + + h.type === "income") ? "Доходы (остаток)" : "Доходы"} fill={COLORS.income} radius={[3, 3, 0, 0]} /> + h.type === "expense") ? "Расходы (остаток)" : "Расходы"} fill={COLORS.expense} radius={[3, 3, 0, 0]} /> + {chartHighlights.map((h, i) => ( + + ))} + + + + + + + )} + + {((data.excludedIncomeCategories?.length ?? 0) > 0 || + (data.excludedExpenseCategories?.length ?? 0) > 0) && ( + + + + + Категории, помеченные как исключённые + + + +

+ Эти категории помечены как исключённые в настройках дерева, но их суммы включены в общий расчёт. +

+
+ {(data.excludedIncomeCategories?.length ?? 0) > 0 && ( +
+

Доходы

+
+ {data.excludedIncomeCategories!.map((cat, i) => ( +
+ {cat.name} + {formatCurrency(cat.total)} +
+ ))} +
+
+ )} + {(data.excludedExpenseCategories?.length ?? 0) > 0 && ( +
+

Расходы

+
+ {data.excludedExpenseCategories!.map((cat, i) => ( +
+ {cat.name} + {formatCurrency(cat.total)} +
+ ))} +
+
+ )} +
+
+
+ )} +
+ ); +} + +export default function CalculationsPage() { + const [activeTab, setActiveTab] = useState<"vat" | "profit">( + () => (localStorage.getItem("omegaprom_calc_tab") as "vat" | "profit") || "vat" + ); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [taxRate, setTaxRate] = useState( + () => Number(localStorage.getItem("omegaprom_tax_rate") || "20") + ); + const [ptIncCats, setPtIncCats] = useState>(() => { + try { + const saved = localStorage.getItem("omegaprom_pt_inc_cats"); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch { return new Set(); } + }); + const [ptExpCats, setPtExpCats] = useState>(() => { + try { + const saved = localStorage.getItem("omegaprom_pt_exp_cats"); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch { return new Set(); } + }); + const [chartHighlights, setChartHighlights] = useState(() => { + try { + const saved = localStorage.getItem("omegaprom_pt_chart_highlights"); + return saved ? JSON.parse(saved) : []; + } catch { return []; } + }); + + function switchTab(tab: "vat" | "profit") { + setActiveTab(tab); + localStorage.setItem("omegaprom_calc_tab", tab); + } + + function changeTaxRate(v: number) { + setTaxRate(v); + localStorage.setItem("omegaprom_tax_rate", String(v)); + } + + function changePtIncCats(ids: Set) { + setPtIncCats(ids); + localStorage.setItem("omegaprom_pt_inc_cats", JSON.stringify([...ids])); + } + + function changePtExpCats(ids: Set) { + setPtExpCats(ids); + localStorage.setItem("omegaprom_pt_exp_cats", JSON.stringify([...ids])); + } + + function changeChartHighlights(highlights: ChartHighlight[]) { + setChartHighlights(highlights); + localStorage.setItem("omegaprom_pt_chart_highlights", JSON.stringify(highlights)); + } + + const vatQuery = useVat(dateFrom || undefined, dateTo || undefined); + + const ptIncArr = useMemo(() => [...ptIncCats], [ptIncCats]); + const ptExpArr = useMemo(() => [...ptExpCats], [ptExpCats]); + const chartHighlightsKey = useMemo(() => chartHighlights.map(h => `${h.type}:${h.id}`).sort(), [chartHighlights]); + + const profitTax = useProfitTax(); + + // Автопересчёт налога на прибыль при изменении периода, ставки или выбора категорий + const profitRequestKey = useMemo(() => JSON.stringify({ + tab: activeTab, + dateFrom, + dateTo, + taxRate, + inc: ptIncArr, + exp: ptExpArr, + hl: chartHighlightsKey, + }), [activeTab, dateFrom, dateTo, taxRate, ptIncArr, ptExpArr, chartHighlightsKey]); + + useEffect(() => { + if (activeTab !== "profit") return; + profitTax.mutate({ + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + taxRate, + incomeCategories: ptIncArr, + expenseCategories: ptExpArr, + chartCategories: chartHighlights.map(h => ({ id: h.id, type: h.type, name: h.name }) satisfies ChartCategoryRef), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profitRequestKey]); + + const resetDates = () => { setDateFrom(""); setDateTo(""); }; + const hasDates = dateFrom || dateTo; + + const isLoading = activeTab === "vat" ? vatQuery.isLoading : profitTax.isPending; + const isError = activeTab === "vat" ? vatQuery.isError : profitTax.isError; + + return ( + +
+
+
+

Расчёты

+

НДС и налог на прибыль по данным финансовой БД

+
+
+ + setDateFrom(e.target.value)} + className="bg-transparent border-none h-7 w-[130px] text-sm px-1" + /> + — + setDateTo(e.target.value)} + className="bg-transparent border-none h-7 w-[130px] text-sm px-1" + /> + {hasDates && ( + + )} +
+
+ + {}} /> + +
+ + +
+ + {isError && } + + {isLoading && ( +
+
+ {[1, 2, 3].map(i => )} +
+ +
+ )} + + {activeTab === "vat" && vatQuery.data && !isLoading && } + {activeTab === "profit" && profitTax.data && !isLoading && ( + + )} +
+
+ ); +} diff --git a/client/src/pages/finance/debt.tsx b/client/src/pages/finance/debt.tsx new file mode 100644 index 0000000..63d9266 --- /dev/null +++ b/client/src/pages/finance/debt.tsx @@ -0,0 +1,651 @@ +import { useState, useMemo, Fragment } from "react"; +import { FinanceLayout } from "@/components/finance/FinanceLayout"; +import { FinanceNav } from "@/components/finance/FinanceNav"; +import { CopyableCell } from "@/components/finance/CopyableCell"; +import { DatabaseError } from "@/components/finance/DatabaseError"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + ChevronLeft, + ChevronRight, + ChevronDown, + ChevronUp, + RotateCcw, + Clock, + Search, + Filter, +} from "lucide-react"; +import { formatCurrency, formatNumber } from "@/lib/format"; +import { + useDebtAnalytics, + useDebtFilters, + useDebtDynamics, + useDebtKontragenty, + type DebtAnalytics, +} from "@/hooks/useFinance"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + LineChart, + Line, + Cell, +} from "recharts"; + +const OVERDUE_COLORS: Record = { + cls0: "#22c55e", + cls1_30: "#3b82f6", + cls31_60: "#eab308", + cls61_90: "#f97316", + cls91_365: "#ef4444", + cls365plus: "#991b1b", +}; + +const OVERDUE_LABELS: Record = { + cls0: "Не просрочено", + cls1_30: "1-30", + cls31_60: "31-60", + cls61_90: "61-90", + cls91_365: "91-365", + cls365plus: "> 365", +}; + +const CLS_KEYS = ["cls0", "cls1_30", "cls31_60", "cls61_90", "cls91_365", "cls365plus"] as const; + +function formatCompact(value: number): string { + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}K`; + return value.toFixed(0); +} + +function formatThousands(value: number): string { + return new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value / 1000); +} + +function formatDateTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString("ru-RU", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} + +function MultiSelect({ label, options, selected, onChange }: { + label: string; + options: string[]; + selected: string[]; + onChange: (v: string[]) => void; +}) { + const displayText = selected.length === 0 + ? "Не выбрано" + : selected.length <= 2 + ? selected.join(", ") + : `${selected.length} выбрано`; + + return ( + + + + + +
{label}
+ {options.map((opt) => ( + + ))} + {selected.length > 0 && ( + + )} +
+
+ ); +} + +function HorizontalStackedChart({ title, data, height }: { title: string; data: any[]; height: number }) { + if (!data || data.length === 0) return null; + + return ( + + +

{title}

+
+ + + + + + [formatCurrency(value), OVERDUE_LABELS[name] || name]} + labelFormatter={(label: string) => label} + /> + OVERDUE_LABELS[value] || value} wrapperStyle={{ fontSize: 10 }} /> + {CLS_KEYS.map(key => ( + + ))} + + +
+
+
+ ); +} + +function SimpleBarChart({ title, data, height }: { title: string; data: { cls: string; total: number }[]; height: number }) { + if (!data || data.length === 0) return null; + + const clsColorMap: Record = { + "Не просрочено": "#22c55e", + "1-30": "#3b82f6", + "31-60": "#eab308", + "61-90": "#f97316", + "91-365": "#ef4444", + "> 365": "#991b1b", + }; + + const chartData = data.map(d => ({ + ...d, + fill: clsColorMap[d.cls] || "#94a3b8", + })); + + return ( + + +

{title}

+
+ + + + + + [formatCurrency(value), "Сумма"]} /> + + {chartData.map((entry, i) => ( + + ))} + + + +
+
+
+ ); +} + +function DebtTableSection({ analytics, page, setPage }: { analytics: DebtAnalytics; page: number; setPage: (p: number) => void }) { + const [expandedRow, setExpandedRow] = useState(null); + const { table } = analytics; + + return ( + + +
+

Таблица расшифровки ДЗ, млн. ₽

+
+
+ + + + + + + + + + + {table.rows.map((group) => { + const isExpanded = expandedRow === group.kontragent; + return ( + + setExpandedRow(isExpanded ? null : group.kontragent)} + > + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
КонтрагентОДЗ, ₽ПДЗ, ₽
+ {isExpanded ? : } + {formatCurrency(group.totalDebt)}{formatCurrency(group.totalOverdue)}
+
+ + + + + + + + + + + + + + + {group.rows.map((row, j) => ( + + + + + + + + + + + ))} + +
ДоговорСумма ДоговораДокументОплаченоОДЗ, ₽ПДЗ, ₽Отсрочка по договоруДней просрочки
{formatCurrency(row.summadokumenta)}{formatCurrency(row.summaoplacheno)}{formatCurrency(row.summadolga)}{formatCurrency(row.summaprosrocheno)}{row.otsrochkapodogovoru}{Math.round(row.dneiprosrocheno)}
+
+
+
+ + {table.totalPages > 1 && ( +
+ + Стр. {table.page} из {table.totalPages} ({table.total} контрагентов) + +
+ + +
+
+ )} +
+
+ ); +} + +function DZTab() { + const [period, setPeriod] = useState(""); + const [selectedKlasspy, setSelectedKlasspy] = useState([]); + const [selectedKontragent, setSelectedKontragent] = useState([]); + const [dogovorSearch, setDogovorSearch] = useState(""); + const [selectedManager, setSelectedManager] = useState([]); + const [page, setPage] = useState(1); + + const analyticsParams = useMemo(() => ({ + period: period || undefined, + klassprosrochki: selectedKlasspy, + kontragent: selectedKontragent, + dogovor: dogovorSearch || undefined, + otvetstvennii: selectedManager, + page, + pageSize: 20, + }), [period, selectedKlasspy, selectedKontragent, dogovorSearch, selectedManager, page]); + + const { data: analytics, isLoading, isError } = useDebtAnalytics(analyticsParams); + const { data: filters } = useDebtFilters(); + + const hasFilters = period || selectedKlasspy.length > 0 || selectedKontragent.length > 0 || dogovorSearch || selectedManager.length > 0; + + function resetFilters() { + setPeriod(""); + setSelectedKlasspy([]); + setSelectedKontragent([]); + setDogovorSearch(""); + setSelectedManager([]); + setPage(1); + } + + const chartHeight = useMemo(() => { + if (!analytics) return 250; + const maxItems = Math.max(analytics.byKontragent.length, analytics.byManager.length, analytics.byOverdueClass.length); + return Math.max(200, maxItems * 35 + 60); + }, [analytics]); + + return ( +
+ {isError && } +
+ {isLoading ? ( + <> + {[1, 2, 3].map(i => ( + + ))} + + ) : analytics?.summary && ( + <> + + +
+ + Дата и время обновления +
+
+ {analytics.summary.updatedAt ? formatDateTime(analytics.summary.updatedAt) : "—"} +
+
+
+ + +

Общая сумма ДЗ, тыс. ₽

+

+ {formatThousands(analytics.summary.totalDebt)} +

+
+
+ + +

Общая сумма ПДЗ, тыс. ₽

+

+ {formatThousands(analytics.summary.totalOverdue)} +

+
+
+ + )} +
+ + + +
+
+ + { setPeriod(e.target.value); setPage(1); }} + className="h-9 text-xs w-[150px]" + /> +
+
+ + { setSelectedKlasspy(v); setPage(1); }} + /> +
+
+ + { setSelectedKontragent(v); setPage(1); }} + /> +
+
+ +
+ + { setDogovorSearch(e.target.value); setPage(1); }} + placeholder="Поиск..." + className="h-9 text-xs pl-8 w-[150px]" + /> +
+
+
+ + { setSelectedManager(v); setPage(1); }} + /> +
+ {hasFilters && ( + + )} +
+
+
+ + {isLoading ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : analytics && ( + <> +
+ + + +
+ + + + )} +
+ ); +} + +function DynamicsTab() { + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [kontragent, setKontragent] = useState(""); + + const { data: filters } = useDebtFilters(); + const { data: dynamicsData, isLoading: dynamicsLoading } = useDebtDynamics(dateFrom || undefined, dateTo || undefined, kontragent || undefined); + const { data: kontragentData } = useDebtKontragenty(undefined, dateTo || undefined); + + const chartData = useMemo(() => { + if (!dynamicsData?.rows) return []; + return dynamicsData.rows.map(d => ({ + period: new Date(d.period).toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "2-digit" }), + dz: d.totalDebt, + pdz: d.totalOverdue, + })); + }, [dynamicsData]); + + const latestValues = useMemo(() => { + if (!dynamicsData?.rows || dynamicsData.rows.length === 0) return { dz: 0, pdz: 0 }; + const last = dynamicsData.rows[dynamicsData.rows.length - 1]; + return { dz: last.totalDebt, pdz: last.totalOverdue }; + }, [dynamicsData]); + + return ( +
+ + +
+
+ +
+ setDateFrom(e.target.value)} + className="h-9 text-xs w-[150px]" + /> + — + setDateTo(e.target.value)} + className="h-9 text-xs w-[150px]" + /> +
+
+
+ + + + + + +
setKontragent("")} + > + Все контрагенты +
+ {(filters?.filters?.kontragenty || []).map((k) => ( +
setKontragent(k)} + > + {k} +
+ ))} +
+
+
+
+
+
+ + + +
+

Динамика ДЗ и ПДЗ, млн. ₽

+
+
+
+ ДЗ + {formatCurrency(latestValues.dz)} +
+
+
+ ПДЗ + {formatCurrency(latestValues.pdz)} +
+
+
+ {dynamicsLoading ? ( + + ) : ( +
+ + + + + + [ + formatCurrency(value), + name === "dz" ? "ДЗ" : "ПДЗ" + ]} + labelFormatter={(label: string) => label} + /> + value === "dz" ? "ДЗ" : "ПДЗ"} /> + + + + +
+ )} +
+
+ + {kontragentData?.rows && kontragentData.rows.length > 0 && ( + + +
+ + + + + + + + + + + + + {kontragentData.rows.map((row, i) => ( + + + + + + + + + ))} + +
КонтрагентСумма ДоговораОплаченоОДЗ Руб.ПДЗ Руб.Дней просрочки
{formatCurrency(row.summadokumenta)}{formatCurrency(row.summaoplacheno)}{formatCurrency(row.summadolga)}{formatCurrency(row.summaprosrocheno)}{formatNumber(Math.round(row.dneiprosrocheno))}
+
+
+
+ )} +
+ ); +} + +export default function DebtPage() { + return ( + +
+
+

Дебиторская задолженность

+

Анализ дебиторской задолженности по контрагентам, менеджерам и срокам просрочки

+
+ + {}} /> + + + + ДЗ + Динамика + + + + + + + + + + +
+
+ ); +} diff --git a/client/src/pages/salary.tsx b/client/src/pages/salary.tsx index d4ff3d0..3a59694 100644 --- a/client/src/pages/salary.tsx +++ b/client/src/pages/salary.tsx @@ -5,8 +5,11 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { MultiSelectPopover } from "@/components/MultiSelectPopover"; +import { useToast } from "@/hooks/use-toast"; import { formatCurrency } from "@/lib/utils"; import { useNeof, @@ -16,6 +19,12 @@ import { useDeleteZofAssign, useSalaryDbStatus, useSalaryMonthly, + useGoogleStatus, + useGoogleAuthUrl, + useSalarySync, + useSalaryAreas, + useCreateSalaryArea, + useDeleteSalaryArea, } from "@/hooks/useFinance"; import { Banknote, @@ -29,6 +38,10 @@ import { X, RefreshCw, Database, + LogIn, + ExternalLink, + Trash2, + Plus, } from "lucide-react"; import { BarChart, @@ -771,6 +784,222 @@ function AnalyticsTab() { ); } +// ===================== +// Google Sheets source +// ===================== + +function GoogleSourceSection() { + const { toast } = useToast(); + const { data: googleStatus, isLoading: statusLoading } = useGoogleStatus(); + const { refetch: refetchAuthUrl } = useGoogleAuthUrl(); + const syncMutation = useSalarySync(); + const { data: areasData, isLoading: areasLoading } = useSalaryAreas(); + const createArea = useCreateSalaryArea(); + const deleteArea = useDeleteSalaryArea(); + + const [newName, setNewName] = useState(""); + const [newUrl, setNewUrl] = useState(""); + const [newFioCol, setNewFioCol] = useState("B"); + const [newBazaCol, setNewBazaCol] = useState("F"); + const [newKVydacheCol, setNewKVydacheCol] = useState("J"); + + const loggedIn = googleStatus?.loggedIn === true; + const areas = areasData?.areas ?? []; + + const handleLogin = async () => { + try { + const res = await refetchAuthUrl(); + const url = res.data?.url; + if (url) { + window.open(url, "_blank"); + } else { + toast({ title: "Не удалось получить ссылку авторизации", variant: "destructive" }); + } + } catch { + toast({ title: "Не удалось получить ссылку авторизации", variant: "destructive" }); + } + }; + + const handleSync = () => { + syncMutation.mutate(undefined, { + onSuccess: (data) => { + if (data.authRequired) { + toast({ title: "Требуется авторизация Google", variant: "destructive" }); + return; + } + let description = `Добавлено: ${data.added}, обновлено: ${data.updated}, удалено: ${data.deleted}`; + if (data.errors?.length) description += `. Ошибок: ${data.errors.length}`; + toast({ title: "Синхронизация завершена", description }); + }, + onError: (err) => { + toast({ title: "Ошибка синхронизации", description: err.message, variant: "destructive" }); + }, + }); + }; + + const handleAddArea = () => { + if (!newName.trim() || !newUrl.trim()) { + toast({ title: "Заполните название и ссылку на таблицу", variant: "destructive" }); + return; + } + createArea.mutate( + { + name: newName.trim(), + spreadsheetUrl: newUrl.trim(), + fioCol: newFioCol.trim().toUpperCase() || undefined, + bazaCol: newBazaCol.trim().toUpperCase() || undefined, + kVydacheCol: newKVydacheCol.trim().toUpperCase() || undefined, + }, + { + onSuccess: () => { + setNewName(""); + setNewUrl(""); + setNewFioCol("B"); + setNewBazaCol("F"); + setNewKVydacheCol("J"); + }, + } + ); + }; + + return ( + + + Источник данных (Google Sheets) + + +
+ {statusLoading ? ( + + ) : loggedIn ? ( + + + Авторизован: {googleStatus?.email || "Google"} + + ) : ( + + + Google не авторизован + + )} +
+ {!loggedIn && ( + + )} + +
+
+ +
+

Участки ({areas.length})

+ {areasLoading ? ( +
+ {[1, 2].map(i => )} +
+ ) : areas.length === 0 ? ( +

Участки не добавлены

+ ) : ( + areas.map(area => ( +
+
+

{area.name}

+

+ ID: {area.spreadsheetId || "—"} + {area.fioCol || area.bazaCol || area.kVydacheCol + ? ` · Столбцы: ФИО=${area.fioCol || "B"}, База=${area.bazaCol || "F"}, К выдаче=${area.kVydacheCol || "J"}` + : ""} +

+ {area.spreadsheetUrl && ( + + + Открыть таблицу + + )} +
+ +
+ )) + )} +
+ +
+

Добавить участок

+
+
+ + setNewName(e.target.value)} + placeholder="Например: Производство" + className="h-8 text-sm" + /> +
+
+ + setNewUrl(e.target.value)} + placeholder="https://docs.google.com/spreadsheets/d/..." + className="h-8 text-sm" + /> +
+
+
+ +
+
+ + setNewFioCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} /> +
+
+ + setNewBazaCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} /> +
+
+ + setNewKVydacheCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} /> +
+
+
+ +
+
+
+ ); +} + // ===================== // Main Page // ===================== @@ -794,6 +1023,8 @@ export default function SalaryPage() {

Данные из PostgreSQL

+ + ОФ diff --git a/package-lock.json b/package-lock.json index 4cbf25a..b95964d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,7 @@ "express-session": "^1.18.1", "framer-motion": "^11.13.1", "gantt-task-react": "^0.3.9", + "googleapis": "^171.4.0", "helmet": "^8.1.0", "html5-qrcode": "^2.3.8", "input-otp": "^1.4.2", @@ -7511,6 +7512,15 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -8431,6 +8441,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -10010,6 +10029,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -10132,6 +10174,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -10265,6 +10319,56 @@ "react": "^18.0.0" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10376,6 +10480,132 @@ "node": ">=10.13.0" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.3.tgz", + "integrity": "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/googleapis-common/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10394,6 +10624,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -11027,6 +11270,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -12947,6 +13199,44 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -16138,6 +16428,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -16957,6 +17253,15 @@ "node": ">= 14" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index bda3c8a..c1411a6 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "express-session": "^1.18.1", "framer-motion": "^11.13.1", "gantt-task-react": "^0.3.9", + "googleapis": "^171.4.0", "helmet": "^8.1.0", "html5-qrcode": "^2.3.8", "input-otp": "^1.4.2", diff --git a/server/finance/audit.ts b/server/finance/audit.ts new file mode 100644 index 0000000..508b248 --- /dev/null +++ b/server/finance/audit.ts @@ -0,0 +1,566 @@ +import { type Express } from "express"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve } from "path"; +import { query } from "./db-client"; +import { + getTableConfig, + getIncomeCategories, + getExpenseCategories, + getIncomeAutoDetectConfig, + getExpenseAutoDetectConfig, + type IncomeCategoryFile, + type AutoDetectConfig, +} from "./table-config"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +// ===================== +// Аудит целостности финансовых данных (перенесено из Data-Insight2 audit-agent.ts) +// ===================== + +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; sum: number; count: number }[]; + treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[]; +} + +const HISTORY_PATH = resolve(process.cwd(), "data", "finance-audit-history.json"); +const MAX_HISTORY = 50; + +let auditHistory: AuditResult[] = []; + +function loadHistory(): void { + if (!existsSync(HISTORY_PATH)) return; + try { + const parsed = JSON.parse(readFileSync(HISTORY_PATH, "utf-8")); + if (Array.isArray(parsed)) auditHistory = parsed; + } catch { + // повреждённый файл — начинаем с чистой истории + } +} + +function saveHistory(): void { + try { + writeFileSync(HISTORY_PATH, JSON.stringify(auditHistory, null, 2), "utf-8"); + } catch (err) { + console.error("[AUDIT-AGENT] Ошибка сохранения истории:", err); + } +} + +loadHistory(); + +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, 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, 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 { + const cols = new Set(); + 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; +} + +function rowMatchesAutoPath(row: Record, 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, + allFlat: Category[], + autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[], + aggRows: Record[], + 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, + cats: Category[], + autoConfig: AutoDetectConfig, + allowedColumns: string[] +): Promise { + 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.map(c => `"${c}"`).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 = { 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 groupedByTree = new Map(); + 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, 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; 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 = {}; + 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, + cat: Category, + siblings: Category[], + ancestorRuleSets: CategoryRule[][], + ancestorIds: Set, + 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", +]; + +let auditRunning = false; + +export async function runIntegrityAudit(): Promise { + if (auditRunning) throw new Error("Аудит уже выполняется"); + auditRunning = true; + try { + console.log("[AUDIT-AGENT] Запуск аудита целостности данных..."); + + const config = getTableConfig(); + + const [income, expense] = await Promise.all([ + auditDirection( + config.incomeTable, + config.incomeSumColumn, + getIncomeCategories() as Category[], + getIncomeAutoDetectConfig(), + INCOME_COLUMNS + ), + auditDirection( + config.expenseTable, + config.expenseSumColumn, + getExpenseCategories() as Category[], + getExpenseAutoDetectConfig(), + 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(); + saveHistory(); + + 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; + } finally { + auditRunning = false; + } +} + +export function getAuditHistory(): AuditResult[] { + return auditHistory; +} + +// ===================== +// Планировщик: ежедневно в 03:00 (МСК), без node-cron +// ===================== + +const TICK_MS = 60_000; +const MSK_OFFSET_MS = 3 * 60 * 60 * 1000; // МСК = UTC+3 +const AUDIT_HOUR_MSK = 3; + +let lastRunDay: string | null = auditHistory[0]?.timestamp + ? new Date(new Date(auditHistory[0].timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10) + : null; +let schedulerStarted = false; + +function mskToday(): string { + return new Date(Date.now() + MSK_OFFSET_MS).toISOString().slice(0, 10); +} + +function mskHourNow(): number { + return new Date(Date.now() + MSK_OFFSET_MS).getUTCHours(); +} + +async function tick(): Promise { + const today = mskToday(); + if (lastRunDay === today) return; // сегодня уже запускался + if (mskHourNow() < AUDIT_HOUR_MSK) return; // ещё не наступило 03:00 по МСК + lastRunDay = today; + try { + await runIntegrityAudit(); + } catch (err) { + console.error("[AUDIT-AGENT] Ошибка при выполнении аудита:", err); + } +} + +export function startAuditScheduler(): void { + if (schedulerStarted) return; + schedulerStarted = true; + + setInterval(() => { + tick().catch((err) => console.error("[AUDIT-AGENT] Tick error:", err)); + }, TICK_MS); + console.log("[AUDIT-AGENT] Планировщик запущен (ежедневно в 03:00 по МСК)"); + + // Первый запуск через 10 секунд после старта сервера + setTimeout(async () => { + try { + const result = await runIntegrityAudit(); + lastRunDay = new Date(new Date(result.timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10); + } catch (err) { + console.error("[AUDIT-AGENT] Ошибка при начальном аудите:", err); + } + }, 10000); +} + +// ===================== +// HTTP endpoints +// ===================== + +export function registerAuditRoutes(app: Express) { + app.get("/api/audit/history", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + res.json({ success: true, history: getAuditHistory() }); + }); + + app.post("/api/audit/run", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => { + try { + const result = await runIntegrityAudit(); + res.json({ success: true, result }); + } catch (err: any) { + console.error("[AUDIT-AGENT] Ошибка ручного запуска:", err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.get("/api/audit/status", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + res.json({ success: true, last: auditHistory[0] || null, running: auditRunning }); + }); +} diff --git a/server/finance/calculations.ts b/server/finance/calculations.ts new file mode 100644 index 0000000..28024f0 --- /dev/null +++ b/server/finance/calculations.ts @@ -0,0 +1,272 @@ +import { type Express } from "express"; +import { query } from "./db-client"; +import * as cache from "./cache"; +import { + getTableConfig, + getIncomeCategories, + getExpenseCategories, + type IncomeCategoryFile, +} from "./table-config"; +import { applyCategoryRules } from "./category-matching"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +// ===================== +// Расчёты: НДС и налог на прибыль (перенесено из Data-Insight2) +// ===================== + +const parseRate = (r: string): number => { + const m = r.match(/(\d+)/); + return m ? Number(m[1]) / 100 : 0; +}; +const isNoVatRate = (r: string): boolean => parseRate(r) === 0; +const r2 = (v: number) => Math.round(v * 100) / 100; + +function buildDateConds(params: unknown[], dateFrom?: string, dateTo?: string): string { + const conds: string[] = ["1=1"]; + if (dateFrom) { params.push(dateFrom); conds.push(`data >= $${params.length}`); } + if (dateTo) { params.push(dateTo); conds.push(`data <= $${params.length}`); } + return `WHERE ${conds.join(" AND ")}`; +} + +export function registerCalculationRoutes(app: Express) { + app.get("/api/calculations/vat", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { + try { + const { dateFrom, dateTo } = req.query; + const config = getTableConfig(); + const cacheKey = `calc:vat:${dateFrom}:${dateTo}`; + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + const incParams: unknown[] = []; + const expParams: unknown[] = []; + const incWhere = buildDateConds(incParams, dateFrom as string, dateTo as string); + const expWhere = buildDateConds(expParams, dateFrom as string, dateTo as string); + + const [incByRate, expByRate, incMonthly, expMonthly] = await Promise.all([ + query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${incWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, incParams), + query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${expWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, expParams), + query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM("${config.incomeSumColumn}"), 0) as total FROM "${config.incomeTable}" ${incWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, incParams), + query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM("${config.expenseSumColumn}"), 0) as total FROM "${config.expenseTable}" ${expWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, expParams), + ]); + + const incomeByRate = incByRate.rows.map((r: any) => ({ + rate: r.rate, + count: Number(r.cnt), + total: Number(r.total), + vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), + })); + + const expenseByRate = expByRate.rows.map((r: any) => ({ + rate: r.rate, + count: Number(r.cnt), + total: Number(r.total), + vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), + })); + + const totalIncomeVat = incomeByRate.reduce((s: number, r: any) => s + r.vat, 0); + const totalExpenseVat = expenseByRate.reduce((s: number, r: any) => s + r.vat, 0); + + const periodsSet = new Set(); + [...incMonthly.rows, ...expMonthly.rows].forEach((r: any) => periodsSet.add(r.period)); + const periods = [...periodsSet].sort(); + + const monthly = periods.map(p => { + let incVat = 0; + let expVat = 0; + incMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { + const rate = parseRate(r.rate); + incVat += Number(r.total) * rate / (1 + rate); + }); + expMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { + const rate = parseRate(r.rate); + expVat += Number(r.total) * rate / (1 + rate); + }); + return { period: p, incomeVat: r2(incVat), expenseVat: r2(expVat), toPay: r2(incVat - expVat) }; + }); + + const result = { + success: true, + incomeByRate, + expenseByRate, + totalIncomeVat: r2(totalIncomeVat), + totalExpenseVat: r2(totalExpenseVat), + toPay: r2(totalIncomeVat - totalExpenseVat), + monthly, + }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[calculations/vat]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.post("/api/calculations/profit-tax", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { + try { + const { dateFrom, dateTo, taxRate: taxRateParam } = req.query; + const { incomeCategories, expenseCategories, chartCategories } = req.body || {}; + const config = getTableConfig(); + const cacheKey = `calc:profit-tax:${dateFrom}:${dateTo}:${taxRateParam}:${JSON.stringify(req.body || {})}`; + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + // Мапа id узла → { node, parentId } по всем деревьям категорий (для excluded-предков) + function buildNodeMap(cats: IncomeCategoryFile[]): Map { + const map = new Map(); + const walk = (nodes: IncomeCategoryFile[], parentId: string | null) => { + for (const n of nodes) { + map.set(n.id, { node: n, parentId }); + if (n.children?.length) walk(n.children, n.id); + } + }; + walk(cats, null); + return map; + } + function isExcluded(map: Map, id: string): boolean { + let cur = map.get(id); + while (cur) { + if (cur.node.excluded) return true; + cur = cur.parentId ? map.get(cur.parentId) : undefined; + } + return false; + } + + // Выбираем строки за период из базовых таблиц, категорию определяем в JS через applyCategoryRules + async function loadDirection( + table: string, + sumColumn: string, + categories: IncomeCategoryFile[], + catFilter?: string[], + ) { + const params: unknown[] = []; + const where = buildDateConds(params, dateFrom as string, dateTo as string); + const rowsResult = await query( + `SELECT data, "${sumColumn}" as amount, COALESCE(stavkands, 'Без НДС') as rate, TO_CHAR(data, 'YYYY-MM') as period FROM "${table}" ${where}`, + params + ); + const nodeMap = buildNodeMap(categories); + + const exclMap = new Map(); + const chartSet = new Set((catFilter || [])); + const monthlyMap = new Map(); + + let withVat = 0, vatAmount = 0, noVat = 0, total = 0; + + for (const row of rowsResult.rows as any[]) { + const catId = applyCategoryRules(row, categories); + if (!catId) continue; // без категории — в расчёте не участвуют (в источнике их нет в derevo-таблицах) + const node = nodeMap.get(catId); + if (!node) continue; + if (isExcluded(nodeMap, catId)) { + exclMap.set(node.node.name, (exclMap.get(node.node.name) || 0) + (parseFloat(row.amount) || 0)); + continue; + } + if (chartSet.size > 0 && !chartSet.has(catId)) continue; + + const amount = parseFloat(row.amount) || 0; + total += amount; + const period = row.period; + const m = monthlyMap.get(period) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; + m.inc += amount; + if (isNoVatRate(row.rate)) { + noVat += amount; + m.incNoVat += amount; + } else { + const rate = parseRate(row.rate); + const vat = amount * rate / (1 + rate); + withVat += amount; + vatAmount += vat; + m.incVat += amount; + m.incVatAmt += vat; + } + monthlyMap.set(period, m); + } + + const excluded = Array.from(exclMap.entries()) + .filter(([, t]) => t > 0) + .map(([name, t]) => ({ name, total: r2(t) })); + + return { withVat, vatAmount, noVat, total, monthlyMap, excluded }; + } + + const incCatFilter = incomeCategories && Array.isArray(incomeCategories) && incomeCategories.length > 0 ? incomeCategories as string[] : undefined; + const expCatFilter = expenseCategories && Array.isArray(expenseCategories) && expenseCategories.length > 0 ? expenseCategories as string[] : undefined; + + const [inc, exp] = await Promise.all([ + loadDirection(config.incomeTable, config.incomeSumColumn, getIncomeCategories(), incCatFilter), + loadDirection(config.expenseTable, config.expenseSumColumn, getExpenseCategories(), expCatFilter), + ]); + + const incomeWithVatBase = inc.withVat - inc.vatAmount; + const expenseWithVatBase = exp.withVat - exp.vatAmount; + const profit = (incomeWithVatBase - expenseWithVatBase) + (inc.noVat - exp.noVat); + const taxRate = taxRateParam ? Number(taxRateParam) / 100 : 0.20; + const tax = profit > 0 ? profit * taxRate : 0; + + const periodsSet = new Set(); + for (const p of inc.monthlyMap.keys()) periodsSet.add(p); + for (const p of exp.monthlyMap.keys()) periodsSet.add(p); + const periods = [...periodsSet].sort(); + + const monthly = periods.map(p => { + const mi = inc.monthlyMap.get(p) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; + const me = exp.monthlyMap.get(p) || { incVat: 0, incVatAmt: 0, incNoVat: 0, inc: 0 }; + const prof = ((mi.incVat - mi.incVatAmt) - (me.incVat - me.incVatAmt)) + (mi.incNoVat - me.incNoVat); + return { period: p, income: r2(mi.inc), expenses: r2(me.inc), profit: r2(prof), tax: r2(prof > 0 ? prof * taxRate : 0) }; + }); + + // Помесячная агрегация по категориям графиков + let categoryMonthly: { id: string; name: string; type: string; data: { period: string; amount: number }[] }[] = []; + if (chartCategories && Array.isArray(chartCategories) && chartCategories.length > 0) { + const chartCatPromises = (chartCategories as { id: string; type: string; name?: string }[]).map(async (cc) => { + const isIncome = cc.type === "income"; + const table = isIncome ? config.incomeTable : config.expenseTable; + const sumColumn = isIncome ? config.incomeSumColumn : config.expenseSumColumn; + const categories = isIncome ? getIncomeCategories() : getExpenseCategories(); + const nodeMap = buildNodeMap(categories); + const catNode = nodeMap.get(cc.id); + + const params: unknown[] = []; + const where = buildDateConds(params, dateFrom as string, dateTo as string); + const rowsResult = await query( + `SELECT "${sumColumn}" as amount, TO_CHAR(data, 'YYYY-MM') as period FROM "${table}" ${where}`, + params + ); + + const periodMap = new Map(); + for (const row of rowsResult.rows as any[]) { + const catId = applyCategoryRules(row, categories); + if (catId !== cc.id) continue; + if (catNode && isExcluded(nodeMap, cc.id)) continue; + const period = row.period; + periodMap.set(period, (periodMap.get(period) || 0) + (parseFloat(row.amount) || 0)); + } + + return { + id: cc.id, + name: cc.name || (catNode?.node.name ?? cc.id), + type: cc.type, + data: [...periodMap.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([period, amount]) => ({ period, amount: r2(amount) })), + }; + }); + categoryMonthly = await Promise.all(chartCatPromises); + } + + const result = { + success: true, + income: r2(inc.total), expenses: r2(exp.total), + totalIncome: r2(inc.total), totalExpenses: r2(exp.total), + incomeWithVat: r2(inc.withVat), incomeVatAmount: r2(inc.vatAmount), incomeWithVatBase: r2(incomeWithVatBase), incomeNoVat: r2(inc.noVat), + expenseWithVat: r2(exp.withVat), expenseVatAmount: r2(exp.vatAmount), expenseWithVatBase: r2(expenseWithVatBase), expenseNoVat: r2(exp.noVat), + profit: r2(profit), taxRate, tax: r2(tax), monthly, + excludedIncomeCategories: inc.excluded, excludedExpenseCategories: exp.excluded, + categoryMonthly, + }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[calculations/profit-tax]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); +} diff --git a/server/finance/category-matching.ts b/server/finance/category-matching.ts new file mode 100644 index 0000000..15d5418 --- /dev/null +++ b/server/finance/category-matching.ts @@ -0,0 +1,36 @@ +// Матчинг строк финансовых таблиц по правилам категорий (деревья). +// Переиспользуется routes.ts (tree-summary, категории) и calculations.ts (налог на прибыль). + +export function matchRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { + const val = row[rule.column]; + const strVal = val == null ? "" : String(val); + const numVal = parseFloat(strVal); + const ruleNum = parseFloat(rule.value); + + switch (rule.operator) { + case "equals": return strVal === rule.value; + case "not_equals": return strVal !== rule.value; + case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase()); + case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum; + case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum; + case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum; + default: return false; + } +} + +// Возвращает id глубочайшей совпавшей категории или null +export function applyCategoryRules(row: Record, categories: any[]): string | null { + for (const cat of categories) { + const match = cat.ruleLogic === "or" + ? cat.rules.some((r: any) => matchRule(row, r)) + : cat.rules.every((r: any) => matchRule(row, r)); + if (match) { + if (cat.children && cat.children.length > 0) { + const child = applyCategoryRules(row, cat.children); + if (child) return child; + } + return cat.id; + } + } + return null; +} diff --git a/server/finance/db-client.ts b/server/finance/db-client.ts index fb16e65..75ece90 100644 --- a/server/finance/db-client.ts +++ b/server/finance/db-client.ts @@ -75,6 +75,12 @@ export async function query(sql: string, params? return p.query(sql, params); } +// Клиент для ручных транзакций (BEGIN/COMMIT/ROLLBACK) — см. google-sheets.ts (синхронизация зарплат) +export async function getPoolClient(): Promise { + const p = getPool(); + return p.connect(); +} + export async function testConnection(config: DbConfig): Promise<{ ok: boolean; message: string }> { const testPool = createPool(config); try { diff --git a/server/finance/debt.ts b/server/finance/debt.ts new file mode 100644 index 0000000..622f779 --- /dev/null +++ b/server/finance/debt.ts @@ -0,0 +1,318 @@ +import { type Express } from "express"; +import { query } from "./db-client"; +import * as cache from "./cache"; +import { getTableConfig } from "./table-config"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +// ===================== +// Дебиторская задолженность (перенесено из Data-Insight2) +// ===================== + +function getDebtTable(): string { + return getTableConfig().debtTable || "vw_debt_active"; +} + +async function getMaxPeriod(): Promise { + const cacheKey = "debt:__max_period__"; + const cached = cache.get(cacheKey); + if (cached) return cached; + const res = await query(`SELECT MAX(period) as mp FROM ${getDebtTable()}`); + const mp = res.rows[0]?.mp ? new Date(res.rows[0].mp).toISOString() : new Date().toISOString(); + cache.set(cacheKey, mp); + return mp; +} + +function parseDate(d: unknown): string | null { + if (!d) return null; + return new Date(d as string).toISOString(); +} + +async function buildDebtWhere(req: any) { + const { period, klassprosrochki, kontragent, dogovor, otvetstvennii } = req.query; + const conditions: string[] = []; + const params: unknown[] = []; + + if (period) { params.push(period); conditions.push(`period = $${params.length}`); } + else { const mp = await getMaxPeriod(); params.push(mp); conditions.push(`period = $${params.length}`); } + + if (klassprosrochki) { + const arr = String(klassprosrochki).split(",").filter(Boolean); + if (arr.length > 0) { + const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); + conditions.push(`klassprosrochki IN (${placeholders.join(",")})`); + } + } + if (kontragent) { + const arr = String(kontragent).split(",").filter(Boolean); + if (arr.length > 0) { + const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); + conditions.push(`kontragent IN (${placeholders.join(",")})`); + } + } + if (dogovor) { + params.push(`%${dogovor}%`); + conditions.push(`dogovor ILIKE $${params.length}`); + } + if (otvetstvennii) { + const arr = String(otvetstvennii).split(",").filter(Boolean); + if (arr.length > 0) { + const placeholders = arr.map(v => { params.push(v); return `$${params.length}`; }); + conditions.push(`otvetstvennii IN (${placeholders.join(",")})`); + } + } + + return { where: `WHERE ${conditions.join(" AND ")}`, params }; +} + +const OVERDUE_CLASSES = ["0", "1-30", "31-60", "61-90", "91-365", "> 365"]; + +function classKey(cls: string | null): string { + if (!cls || cls === "0") return "cls0"; + if (cls === "1-30") return "cls1_30"; + if (cls === "31-60") return "cls31_60"; + if (cls === "61-90") return "cls61_90"; + if (cls === "91-365") return "cls91_365"; + return "cls365plus"; +} + +function debtCacheKey(prefix: string, queryParams: Record): string { + return `${prefix}:${JSON.stringify(queryParams)}`; +} + +export function registerDebtRoutes(app: Express) { + app.get("/api/debt/analytics", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { + try { + const cacheKey = debtCacheKey("debt:analytics", req.query as Record); + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + const { where, params } = await buildDebtWhere(req); + const { page = "1", pageSize = "20" } = req.query; + const pg = Number(page); + const ps = Math.min(Number(pageSize), 100); + const offset = (pg - 1) * ps; + + const [summaryRes, byKontragentRes, byManagerRes, byClassRes, totalRes, updatedRes, pagedKontragentsRes] = await Promise.all([ + query(`SELECT COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue FROM ${getDebtTable()} ${where}`, params), + query(`SELECT kontragent, klassprosrochki, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY kontragent, klassprosrochki`, params), + query(`SELECT otvetstvennii, klassprosrochki, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY otvetstvennii, klassprosrochki`, params), + query(`SELECT CASE WHEN klassprosrochki IS NULL OR klassprosrochki = '0' THEN 'Не просрочено' ELSE klassprosrochki END as cls, COALESCE(SUM(summadolga),0) as total FROM ${getDebtTable()} ${where} GROUP BY cls ORDER BY total DESC`, params), + query(`SELECT COUNT(DISTINCT kontragent) as cnt FROM ${getDebtTable()} ${where}`, params), + query(`SELECT MAX(dataobnovleniyazapisi) as updated_at FROM ${getDebtTable()} ${where}`, params), + query(`SELECT kontragent, COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue FROM ${getDebtTable()} ${where} GROUP BY kontragent ORDER BY total_debt DESC LIMIT ${ps} OFFSET ${offset}`, params), + ]); + + const kontragentMap = new Map(); + byKontragentRes.rows.forEach((r: any) => { + const name = r.kontragent || "Без контрагента"; + if (!kontragentMap.has(name)) kontragentMap.set(name, { name, total: 0, cls0: 0, cls1_30: 0, cls31_60: 0, cls61_90: 0, cls91_365: 0, cls365plus: 0 }); + const entry = kontragentMap.get(name)!; + const val = Number(r.total); + entry.total += val; + entry[classKey(r.klassprosrochki)] += val; + }); + const byKontragent = Array.from(kontragentMap.values()).sort((a, b) => b.total - a.total).slice(0, 10); + + const managerMap = new Map(); + byManagerRes.rows.forEach((r: any) => { + const name = r.otvetstvennii && r.otvetstvennii !== "0" ? r.otvetstvennii : "Не назначен"; + if (!managerMap.has(name)) managerMap.set(name, { name, total: 0, cls0: 0, cls1_30: 0, cls31_60: 0, cls61_90: 0, cls91_365: 0, cls365plus: 0 }); + const entry = managerMap.get(name)!; + const val = Number(r.total); + entry.total += val; + entry[classKey(r.klassprosrochki)] += val; + }); + const byManager = Array.from(managerMap.values()).sort((a, b) => b.total - a.total).slice(0, 10); + + const byOverdueClass = byClassRes.rows.map((r: any) => ({ cls: r.cls, total: Number(r.total) })); + + const pagedKontragentNames = pagedKontragentsRes.rows.map((r: any) => r.kontragent); + let pagedGroups: any[] = []; + const totalKontragents = Number(totalRes.rows[0].cnt); + + if (pagedKontragentNames.length > 0) { + const detailParams = [...params]; + const namePlaceholders = pagedKontragentNames.map((n: string) => { detailParams.push(n); return `$${detailParams.length}`; }); + const detailRes = await query(` + SELECT kontragent, dogovor, COALESCE(summadokumenta,0) as summadokumenta, dokumentraschetov, + COALESCE(summaoplacheno,0) as summaoplacheno, COALESCE(summadolga,0) as summadolga, + COALESCE(summaprosrocheno,0) as summaprosrocheno, COALESCE(otsrochkapodogovoru,0) as otsrochkapodogovoru, + COALESCE(dneiprosrocheno,0) as dneiprosrocheno + FROM ${getDebtTable()} ${where} AND kontragent IN (${namePlaceholders.join(",")}) + ORDER BY kontragent, summadolga DESC + `, detailParams); + + const groupMap = new Map(); + pagedKontragentsRes.rows.forEach((r: any) => { + groupMap.set(r.kontragent, { kontragent: r.kontragent, totalDebt: Number(r.total_debt), totalOverdue: Number(r.total_overdue), rows: [] }); + }); + detailRes.rows.forEach((r: any) => { + const g = groupMap.get(r.kontragent); + if (g) { + g.rows.push({ + dogovor: r.dogovor || "", + summadokumenta: Number(r.summadokumenta), + dokumentraschetov: r.dokumentraschetov || "", + summaoplacheno: Number(r.summaoplacheno), + summadolga: Number(r.summadolga), + summaprosrocheno: Number(r.summaprosrocheno), + otsrochkapodogovoru: Number(r.otsrochkapodogovoru), + dneiprosrocheno: Number(r.dneiprosrocheno), + }); + } + }); + pagedGroups = pagedKontragentsRes.rows.map((r: any) => groupMap.get(r.kontragent)!); + } + + const result = { + success: true, + summary: { + updatedAt: updatedRes.rows[0]?.updated_at ? parseDate(updatedRes.rows[0].updated_at) : null, + totalDebt: Number(summaryRes.rows[0].total_debt), + totalOverdue: Number(summaryRes.rows[0].total_overdue), + }, + byKontragent, + byManager, + byOverdueClass, + table: { + rows: pagedGroups, + total: totalKontragents, + page: pg, + pageSize: ps, + totalPages: Math.ceil(totalKontragents / ps), + }, + }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[debt/analytics]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.get("/api/debt/dynamics", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { + try { + const cacheKey = debtCacheKey("debt:dynamics", req.query as Record); + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + const { dateFrom, dateTo, kontragent } = req.query; + const conditions: string[] = []; + const params: unknown[] = []; + + if (dateFrom) { params.push(dateFrom); conditions.push(`period >= $${params.length}`); } + if (dateTo) { params.push(dateTo); conditions.push(`period <= $${params.length}`); } + if (kontragent) { params.push(kontragent); conditions.push(`kontragent = $${params.length}`); } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const dbResult = await query(` + SELECT period, COALESCE(SUM(summadolga),0) as total_debt, COALESCE(SUM(summaprosrocheno),0) as total_overdue + FROM ${getDebtTable()} ${where} + GROUP BY period + ORDER BY period + `, params); + + let rows = dbResult.rows.map((r: any) => ({ + period: parseDate(r.period), + totalDebt: Number(r.total_debt), + totalOverdue: Number(r.total_overdue), + })); + + if (rows.length > 300) { + const step = Math.ceil(rows.length / 300); + rows = rows.filter((_: any, i: number) => i % step === 0 || i === rows.length - 1); + } + + const result = { success: true, rows }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[debt/dynamics]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.get("/api/debt/dynamics/kontragenty", authenticateToken, requirePermission('finance.manage'), async (req: AuthenticatedRequest, res) => { + try { + const cacheKey = debtCacheKey("debt:dyn-kontr", req.query as Record); + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + const { period, dateTo } = req.query; + const params: unknown[] = []; + let periodClause: string; + + if (period) { + params.push(period); + periodClause = `period = $${params.length}`; + } else if (dateTo) { + params.push(dateTo); + periodClause = `period = (SELECT MAX(period) FROM ${getDebtTable()} WHERE period <= $${params.length})`; + } else { + const mp = await getMaxPeriod(); + params.push(mp); + periodClause = `period = $${params.length}`; + } + + const where = `WHERE ${periodClause}`; + + const dbResult = await query(` + SELECT kontragent, COALESCE(SUM(summadokumenta),0) as summadokumenta, COALESCE(SUM(summaoplacheno),0) as summaoplacheno, + COALESCE(SUM(summadolga),0) as summadolga, COALESCE(SUM(summaprosrocheno),0) as summaprosrocheno, + MAX(dneiprosrocheno) as dneiprosrocheno + FROM ${getDebtTable()} ${where} + GROUP BY kontragent ORDER BY summadolga DESC + `, params); + + const result = { + success: true, + rows: dbResult.rows.map((r: any) => ({ + kontragent: r.kontragent, + summadokumenta: Number(r.summadokumenta), + summaoplacheno: Number(r.summaoplacheno), + summadolga: Number(r.summadolga), + summaprosrocheno: Number(r.summaprosrocheno), + dneiprosrocheno: Number(r.dneiprosrocheno), + })), + }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[debt/dynamics/kontragenty]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.get("/api/debt/filters", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => { + try { + const cacheKey = "debt:filters"; + const cached = cache.get(cacheKey); + if (cached) { res.json(cached); return; } + + const maxPeriod = await getMaxPeriod(); + const [periods, klassprosrochki, organizaciya, otvetstvennii, kontragenty] = await Promise.all([ + query(`SELECT DISTINCT period FROM ${getDebtTable()} ORDER BY period DESC LIMIT 30`), + query(`SELECT DISTINCT klassprosrochki FROM ${getDebtTable()} WHERE klassprosrochki IS NOT NULL ORDER BY klassprosrochki`), + query(`SELECT DISTINCT organizaciya FROM ${getDebtTable()} WHERE organizaciya IS NOT NULL ORDER BY organizaciya`), + query(`SELECT DISTINCT otvetstvennii FROM ${getDebtTable()} WHERE period = $1 AND otvetstvennii IS NOT NULL AND otvetstvennii != '0' ORDER BY otvetstvennii`, [maxPeriod]), + query(`SELECT DISTINCT kontragent FROM ${getDebtTable()} WHERE period = $1 AND kontragent IS NOT NULL ORDER BY kontragent`, [maxPeriod]), + ]); + const result = { + success: true, + filters: { + periods: periods.rows.map(r => parseDate(r.period)), + klassprosrochki: klassprosrochki.rows.map(r => r.klassprosrochki), + organizaciya: organizaciya.rows.map(r => r.organizaciya), + otvetstvennii: otvetstvennii.rows.map(r => r.otvetstvennii), + kontragenty: kontragenty.rows.map(r => r.kontragent), + }, + }; + cache.set(cacheKey, result); + res.json(result); + } catch (err: any) { + console.error("[debt/filters]", err); + res.status(500).json({ success: false, error: err.message }); + } + }); +} diff --git a/server/finance/google-sheets.ts b/server/finance/google-sheets.ts new file mode 100644 index 0000000..bcbd0da --- /dev/null +++ b/server/finance/google-sheets.ts @@ -0,0 +1,494 @@ +import { type Express } from "express"; +import { google } from "googleapis"; +import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "fs"; +import { resolve, dirname } from "path"; +import { query, getPoolClient } from "./db-client"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +// ===================== +// Google OAuth + участки + синхронизация зарплат (перенесено из Data-Insight2) +// ===================== + +const TOKENS_PATH = resolve(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 { + return process.env.FINANCE_GOOGLE_REDIRECT_URI || "https://iistwin.ru/api/finance/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 (existsSync(TOKENS_PATH)) { + return JSON.parse(readFileSync(TOKENS_PATH, "utf-8")); + } + } catch {} + return null; +} + +function saveTokens(tokens: StoredTokens): void { + const dir = dirname(TOKENS_PATH); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2), "utf-8"); +} + +function clearTokens(): void { + try { + if (existsSync(TOKENS_PATH)) unlinkSync(TOKENS_PATH); + } catch {} +} + +export function getAuthUrl(): string { + const client = createOAuth2Client(); + return client.generateAuthUrl({ + access_type: "offline", + scope: SCOPES, + prompt: "consent", + }); +} + +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 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: any) => { + 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; +} + +async function getSheetNames(spreadsheetId: string): Promise { + 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: any) => s.properties?.title || "").filter(Boolean); +} + +async function getSheetData(spreadsheetId: string, range: string): Promise { + const auth = await getAuthenticatedClient(); + const sheets = google.sheets({ version: "v4", auth }); + const res = await sheets.spreadsheets.values.get({ spreadsheetId, range }); + return res.data.values || []; +} + +// ===================== +// Участки (data/salary-areas.json) +// ===================== + +const SALARY_AREAS_PATH = resolve(process.cwd(), "data", "salary-areas.json"); + +interface SalaryArea { + id: string; + name: string; + spreadsheetId: string; + fioCol: string; + bazaCol: string; + kVydacheCol: string; +} + +function colLetterToIndex(col: string): number { + const c = col.toUpperCase(); + let idx = 0; + for (let i = 0; i < c.length; i++) { + idx = idx * 26 + (c.charCodeAt(i) - 64); + } + return idx - 1; +} + +function maxColLetter(...cols: string[]): string { + let max = cols[0]; + for (const c of cols) { + if (colLetterToIndex(c) > colLetterToIndex(max)) max = c; + } + return max.toUpperCase(); +} + +function loadAreas(): SalaryArea[] { + try { + if (existsSync(SALARY_AREAS_PATH)) { + const raw = JSON.parse(readFileSync(SALARY_AREAS_PATH, "utf-8")); + return raw.map((a: any) => ({ + fioCol: "B", + bazaCol: "F", + kVydacheCol: "J", + ...a, + })); + } + } catch {} + return []; +} + +function saveAreas(areas: any[]): void { + writeFileSync(SALARY_AREAS_PATH, JSON.stringify(areas, null, 2), "utf-8"); +} + +function extractSpreadsheetId(url: string): string | null { + const m = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/); + return m ? m[1] : null; +} + +const COL_PATTERN = /^[A-Za-z]{1,2}$/; + +// ===================== +// Загрузка данных зарплат из Google Sheets +// ===================== + +const MONTH_PATTERN = /^(\d{1,2})\.(\d{2})$/; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function withRetry(fn: () => Promise, maxRetries = 4, baseDelayMs = 1000): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err: any) { + const msg = String(err?.message || ""); + const status = err?.statusCode || err?.code || err?.response?.status; + const isQuota = msg.includes("Quota exceeded") || msg.includes("quota") || status === 429 || msg.includes("429") || msg.includes("RATE_LIMIT") || msg.includes("rate limit"); + if (!isQuota || attempt === maxRetries) throw err; + const delayMs = baseDelayMs * Math.pow(2, attempt); + console.log(`[opneof] retry ${attempt + 1}/${maxRetries} after ${delayMs}ms — ${msg.slice(0, 120)}`); + await sleep(delayMs); + } + } + throw new Error("withRetry: unreachable"); +} + +async function fetchNeofFromSheets(targetAreas: SalaryArea[]): Promise { + const results: any[] = []; + for (const area of targetAreas) { + try { + const sheetNames = await withRetry(() => getSheetNames(area.spreadsheetId)); + const monthSheets = sheetNames + .filter(name => MONTH_PATTERN.test(name)) + .sort((a, b) => { + const [, am, ay] = a.match(MONTH_PATTERN)!; + const [, bm, by] = b.match(MONTH_PATTERN)!; + return (Number(ay) * 12 + Number(am)) - (Number(by) * 12 + Number(bm)); + }); + + const months: { month: string; employees: any[] }[] = []; + for (const sheetName of monthSheets) { + const fioIdx = colLetterToIndex(area.fioCol || "B"); + const bazaIdx = colLetterToIndex(area.bazaCol || "F"); + const kVydacheIdx = colLetterToIndex(area.kVydacheCol || "J"); + const lastCol = maxColLetter(area.fioCol || "B", area.bazaCol || "F", area.kVydacheCol || "J"); + const rows = await withRetry(() => getSheetData(area.spreadsheetId, `'${sheetName}'!A4:${lastCol}500`)); + const employees = rows + .filter(row => row && row[fioIdx] && String(row[fioIdx]).trim()) + .map(row => { + const rawFio = String(row[fioIdx] || ""); + const fio = rawFio.replace(/[0-9]/g, "").replace(/[^\p{L}\s\-().]/gu, "").replace(/\s+/g, " ").trim(); + if (!fio) return null; + const baza = Number(String(row[bazaIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; + const kVydache = Number(String(row[kVydacheIdx] || "0").replace(/\s/g, "").replace(",", ".")) || 0; + return { fio, baza, kVydache }; + }) + .filter(Boolean); + + months.push({ month: sheetName, employees }); + await sleep(200); + } + + results.push({ id: area.id, name: area.name, months: months.filter(m => m.employees.length > 0) }); + } catch (err: any) { + console.error(`Error fetching area ${area.name}:`, err?.message); + results.push({ id: area.id, name: area.name, months: [], error: err?.message || "Ошибка загрузки" }); + } + await sleep(600); + } + return results; +} + +// Таблица opneof создаётся в salary-routes.ts (ensureTables) +async function ensureOpneof(): Promise { + try { + const r = await query(`SELECT to_regclass('opneof') as reg`); + return !!r.rows[0]?.reg; + } catch { + return false; + } +} + +let syncInProgress = false; + +// ===================== +// Routes +// ===================== + +export function registerGoogleSheetsRoutes(app: Express) { + // --- Google OAuth --- + app.get("/api/finance/google/status", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + res.json({ success: true, ...getAuthStatus() }); + }); + + app.get("/api/finance/google/auth-url", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + res.json({ success: true, url: getAuthUrl() }); + }); + + app.get("/api/finance/google/callback", async (req, res) => { + try { + const code = req.query.code as string; + if (!code) { res.redirect("/salary?google_auth=error"); return; } + await handleCallback(code); + res.redirect("/salary?google_auth=success"); + } catch (err) { + console.error("[google callback]", err); + res.redirect("/salary?google_auth=error"); + } + }); + + app.post("/api/finance/google/logout", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + clearTokens(); + res.json({ success: true }); + }); + + // --- Участки --- + app.get("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (_req: AuthenticatedRequest, res) => { + res.json({ success: true, areas: loadAreas() }); + }); + + app.post("/api/salary/areas", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { + try { + const { name, spreadsheetUrl, fioCol = "B", bazaCol = "F", kVydacheCol = "J" } = req.body || {}; + if (!name || !spreadsheetUrl) return res.status(400).json({ success: false, error: "Укажите название участка и ссылку на таблицу" }); + const spreadsheetId = extractSpreadsheetId(spreadsheetUrl); + if (!spreadsheetId) return res.status(400).json({ success: false, error: "Неверная ссылка на Google Таблицу" }); + if (!COL_PATTERN.test(fioCol) || !COL_PATTERN.test(bazaCol) || !COL_PATTERN.test(kVydacheCol)) { + return res.status(400).json({ success: false, error: "Столбцы должны быть буквами (A-Z)" }); + } + const areas = loadAreas(); + const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const area = { id, name, spreadsheetId, fioCol: fioCol.toUpperCase(), bazaCol: bazaCol.toUpperCase(), kVydacheCol: kVydacheCol.toUpperCase() }; + areas.push(area); + saveAreas(areas); + res.json({ success: true, area }); + } catch (err) { + console.error(err); + res.status(500).json({ success: false, error: "Ошибка при добавлении участка" }); + } + }); + + app.patch("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { + try { + const areas = loadAreas(); + const area = areas.find(a => a.id === req.params.id); + if (!area) return res.status(404).json({ success: false, error: "Участок не найден" }); + const { fioCol, bazaCol, kVydacheCol } = req.body || {}; + if (fioCol !== undefined) { + if (!COL_PATTERN.test(fioCol)) return res.status(400).json({ success: false, error: "Столбец ФИО должен быть буквой (A-Z)" }); + area.fioCol = fioCol.toUpperCase(); + } + if (bazaCol !== undefined) { + if (!COL_PATTERN.test(bazaCol)) return res.status(400).json({ success: false, error: "Столбец База должен быть буквой (A-Z)" }); + area.bazaCol = bazaCol.toUpperCase(); + } + if (kVydacheCol !== undefined) { + if (!COL_PATTERN.test(kVydacheCol)) return res.status(400).json({ success: false, error: "Столбец К выдаче должен быть буквой (A-Z)" }); + area.kVydacheCol = kVydacheCol.toUpperCase(); + } + saveAreas(areas); + res.json({ success: true, area }); + } catch (err) { + console.error(err); + res.status(500).json({ success: false, error: "Ошибка при обновлении участка" }); + } + }); + + app.delete("/api/salary/areas/:id", authenticateToken, requirePermission('finance.manage'), (req: AuthenticatedRequest, res) => { + try { + let areas = loadAreas(); + areas = areas.filter(a => a.id !== req.params.id); + saveAreas(areas); + res.json({ success: true }); + } catch (err) { + console.error(err); + res.status(500).json({ success: false, error: "Ошибка при удалении участка" }); + } + }); + + // --- Синхронизация зарплат --- + app.post("/api/salary/sync", authenticateToken, requirePermission('finance.manage'), async (_req: AuthenticatedRequest, res) => { + try { + if (syncInProgress) return res.status(409).json({ success: false, error: "Синхронизация уже выполняется" }); + syncInProgress = true; + + const status = getAuthStatus(); + if (!status.loggedIn) { syncInProgress = false; return res.status(401).json({ success: false, error: "Не авторизован в Google", authRequired: true }); } + + if (!(await ensureOpneof())) { syncInProgress = false; return res.status(503).json({ success: false, error: "Таблица opneof не готова" }); } + + const areas = loadAreas(); + if (areas.length === 0) { syncInProgress = false; return res.json({ success: true, added: 0, updated: 0, deleted: 0, total: 0 }); } + + const sheetsData = await fetchNeofFromSheets(areas); + + const failedAreas = sheetsData.filter(a => a.error); + if (failedAreas.length === sheetsData.length) { + syncInProgress = false; + const firstErr = failedAreas[0]?.error || "Неизвестная ошибка"; + if (firstErr.includes("авторизован") || firstErr.includes("Токен") || firstErr.includes("401")) { + return res.status(401).json({ success: false, error: "Ошибка авторизации Google: " + firstErr, authRequired: true }); + } + return res.status(500).json({ success: false, error: "Все участки вернули ошибку: " + firstErr }); + } + + const now = new Date().toISOString(); + + const client = await getPoolClient(); + try { + await client.query("BEGIN"); + let added = 0, updated = 0, deleted = 0; + const errors: string[] = failedAreas.map(a => `${a.name}: ${a.error}`); + + const sheetsKeys = new Set(); + + for (const areaData of sheetsData) { + if (areaData.error) continue; + for (const monthData of areaData.months) { + for (const emp of monthData.employees) { + sheetsKeys.add(`${areaData.id}||${monthData.month}||${emp.fio}`); + + const r = await client.query( + `INSERT INTO opneof (area_id, area_name, month, fio, baza, k_vydache, synced_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NULL) + ON CONFLICT (area_id, month, fio) DO UPDATE + SET baza = EXCLUDED.baza, k_vydache = EXCLUDED.k_vydache, + area_name = EXCLUDED.area_name, synced_at = EXCLUDED.synced_at, deleted_at = NULL + WHERE opneof.baza IS DISTINCT FROM EXCLUDED.baza + OR opneof.k_vydache IS DISTINCT FROM EXCLUDED.k_vydache + OR opneof.area_name IS DISTINCT FROM EXCLUDED.area_name + OR opneof.deleted_at IS NOT NULL + RETURNING (xmax = 0) AS inserted`, + [areaData.id, areaData.name, monthData.month, emp.fio, emp.baza, emp.kVydache, now] + ); + if (r.rows.length === 0) continue; + if (r.rows[0]?.inserted) added++; else updated++; + } + } + } + + for (const areaData of sheetsData) { + if (areaData.error) continue; + const existingRows = await client.query( + "SELECT id, area_id, month, fio FROM opneof WHERE area_id = $1 AND deleted_at IS NULL", + [areaData.id] + ); + for (const row of existingRows.rows) { + if (!sheetsKeys.has(`${row.area_id}||${row.month}||${row.fio}`)) { + await client.query("UPDATE opneof SET deleted_at = $1, synced_at = $1 WHERE id = $2", [now, row.id]); + deleted++; + } + } + } + + await client.query("COMMIT"); + client.release(); + + const totalRes = await query("SELECT COUNT(*) as cnt FROM opneof WHERE deleted_at IS NULL"); + const total = Number(totalRes.rows[0]?.cnt || 0); + + console.log(`[opneof sync] added=${added} updated=${updated} deleted=${deleted} total=${total} errors=${errors.length}`); + syncInProgress = false; + res.json({ success: true, added, updated, deleted, total, errors: errors.length > 0 ? errors : undefined }); + } catch (txErr) { + await client.query("ROLLBACK").catch(() => {}); + client.release(); + throw txErr; + } + } catch (err: any) { + syncInProgress = false; + if (err?.message?.includes("авторизован") || err?.message?.includes("Токен")) { + return res.status(401).json({ success: false, error: err.message, authRequired: true }); + } + console.error("[salary sync] Error:", err); + res.status(500).json({ success: false, error: "Ошибка синхронизации: " + (err?.message || "неизвестная ошибка") }); + } + }); +} diff --git a/server/finance/routes.ts b/server/finance/routes.ts index 8cfa1b9..07866a3 100644 --- a/server/finance/routes.ts +++ b/server/finance/routes.ts @@ -23,6 +23,11 @@ import { tenantIsolation } from "../middleware/tenant.middleware"; import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync"; import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync"; import { getFinanceSettings, setFinanceSettings } from "./settings"; +import { applyCategoryRules } from "./category-matching"; +import { registerDebtRoutes } from "./debt"; +import { registerCalculationRoutes } from "./calculations"; +import { registerAuditRoutes, startAuditScheduler } from "./audit"; +import { registerGoogleSheetsRoutes } from "./google-sheets"; // Rebuild status (in-memory) let rebuildStatus = { inProgress: false, pendingRebuild: false, lastRebuiltAt: null as string | null }; @@ -59,39 +64,6 @@ function buildWhere( return { clause, params, paramIndex: idx }; } -function applyCategoryRules(row: Record, categories: any[]): string | null { - for (const cat of categories) { - const match = cat.ruleLogic === "or" - ? cat.rules.some((r: any) => matchRule(row, r)) - : cat.rules.every((r: any) => matchRule(row, r)); - if (match) { - if (cat.children && cat.children.length > 0) { - const child = applyCategoryRules(row, cat.children); - if (child) return child; - } - return cat.id; - } - } - return null; -} - -function matchRule(row: Record, rule: { column: string; operator: string; value: string }): boolean { - const val = row[rule.column]; - const strVal = val == null ? "" : String(val); - const numVal = parseFloat(strVal); - const ruleNum = parseFloat(rule.value); - - switch (rule.operator) { - case "equals": return strVal === rule.value; - case "not_equals": return strVal !== rule.value; - case "contains": return strVal.toLowerCase().includes(rule.value.toLowerCase()); - case "greater_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal > ruleNum; - case "less_than": return !isNaN(numVal) && !isNaN(ruleNum) && numVal < ruleNum; - case "equal_number": return !isNaN(numVal) && !isNaN(ruleNum) && numVal === ruleNum; - default: return false; - } -} - // ===================== // Connection & Config // ===================== @@ -730,4 +702,9 @@ export async function registerFinanceRoutes(app: Express): Promise { registerAutoDetectRoutes(app); registerTimeseriesRoutes(app); await registerSalaryRoutes(app); + registerDebtRoutes(app); + registerCalculationRoutes(app); + registerAuditRoutes(app); + registerGoogleSheetsRoutes(app); + startAuditScheduler(); } diff --git a/server/finance/table-config.ts b/server/finance/table-config.ts index 607346c..27262e2 100644 --- a/server/finance/table-config.ts +++ b/server/finance/table-config.ts @@ -6,6 +6,7 @@ export interface TableConfig { expenseTable: string; incomeSumColumn: string; expenseSumColumn: string; + debtTable?: string; columnLabels: Record; } @@ -58,6 +59,7 @@ export function getTableConfig(): TableConfig { expenseTable: "expenses", incomeSumColumn: "summa", expenseSumColumn: "summadokumenta", + debtTable: "vw_debt_active", columnLabels: { data: "Дата", summa: "Сумма",