Финансы: дебиторка (vw_debt_active), расчёты НДС/налога на прибыль, аудит целостности с автопланировщиком, синхронизация зарплат из Google Sheets, миграция настроек Data-Insight2
This commit is contained in:
@@ -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 (
|
||||
<Route path="/gps/:tab" component={GpsPage} />
|
||||
<Route path="/billing" component={Billing} />
|
||||
<Route path="/finance" component={FinanceDashboard} />
|
||||
<Route path="/finance/debt" component={DebtPage} />
|
||||
<Route path="/finance/calculations" component={CalculationsPage} />
|
||||
<Route path="/finance/audit" component={AuditPage} />
|
||||
<Route path="/income" component={IncomeOverview} />
|
||||
<Route path="/income/uncategorized" component={IncomeDetail} />
|
||||
<Route path="/income/categories" component={IncomeCategories} />
|
||||
|
||||
@@ -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: <LayoutDashboard className="w-4 h-4" /> },
|
||||
{ key: "debt", label: "Дебиторка", icon: <Wallet className="w-4 h-4" />, href: "/finance/debt" },
|
||||
{ key: "calculations", label: "Расчёты", icon: <Calculator className="w-4 h-4" />, href: "/finance/calculations" },
|
||||
{ key: "audit", label: "Аудит", icon: <ShieldCheck className="w-4 h-4" />, href: "/finance/audit" },
|
||||
{ key: "income-categories", label: "Категории доходов", icon: <Tags className="w-4 h-4" /> },
|
||||
{ key: "expense-categories", label: "Категории расходов", icon: <Tags className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
@@ -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<DebtAnalytics>({
|
||||
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<DebtFiltersResponse>({
|
||||
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<DebtDynamicsResponse>({
|
||||
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<VatResponse>({
|
||||
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<ProfitTaxResponse, Error, ProfitTaxParams>({
|
||||
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<string, unknown> = {};
|
||||
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<string, string>;
|
||||
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<GoogleStatus>({
|
||||
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<SalarySyncResult, Error>({
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
278
client/src/pages/finance/audit.tsx
Normal file
278
client/src/pages/finance/audit.tsx
Normal file
@@ -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 <Badge className="bg-green-500/10 text-green-600 border-green-500/20 hover:bg-green-500/10"><CheckCircle2 className="w-3 h-3 mr-1" /> Всё в порядке</Badge>;
|
||||
if (status === "warning") return <Badge variant="outline" className="bg-yellow-500/10 text-yellow-600 border-yellow-500/20"><AlertTriangle className="w-3 h-3 mr-1" /> Есть расхождения</Badge>;
|
||||
return <Badge variant="destructive"><AlertTriangle className="w-3 h-3 mr-1" /> Ошибка</Badge>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{data.gapCount === 0
|
||||
? <Badge className="bg-green-500/10 text-green-600 border-green-500/20 hover:bg-green-500/10">Gap: 0</Badge>
|
||||
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-600 border-yellow-500/20">Gap: {data.gapCount} строк</Badge>
|
||||
}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Категория</TableHead>
|
||||
<TableHead className="text-right">Строк</TableHead>
|
||||
<TableHead className="text-right">%</TableHead>
|
||||
<TableHead className="text-right">Сумма</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="font-medium bg-muted/30">
|
||||
<TableCell>Всего в БД</TableCell>
|
||||
<TableCell className="text-right">{data.dbCount}</TableCell>
|
||||
<TableCell className="text-right">100%</TableCell>
|
||||
<TableCell className="text-right">{formatMoney(data.dbTotal)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>В деревьях (не исключённые)</TableCell>
|
||||
<TableCell className="text-right">{data.treeCount}</TableCell>
|
||||
<TableCell className="text-right">{pctTree}%</TableCell>
|
||||
<TableCell className="text-right">{formatMoney(data.treeTotal)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Исключённые категории</TableCell>
|
||||
<TableCell className="text-right">{data.excludedCount}</TableCell>
|
||||
<TableCell className="text-right">{pctExcl}%</TableCell>
|
||||
<TableCell className="text-right">{formatMoney(data.excludedTotal)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Нераспределённые</TableCell>
|
||||
<TableCell className="text-right">{data.uncategorizedCount}</TableCell>
|
||||
<TableCell className="text-right">{pctUncat}%</TableCell>
|
||||
<TableCell className="text-right">{formatMoney(data.uncategorizedTotal)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={data.gapCount !== 0 ? "bg-yellow-500/5" : "bg-green-500/5"}>
|
||||
<TableCell className="font-medium">Gap (расхождение)</TableCell>
|
||||
<TableCell className="text-right font-medium">{data.gapCount}</TableCell>
|
||||
<TableCell className="text-right">—</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatMoney(data.gapTotal)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{data.treeBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Деревья</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Дерево</TableHead>
|
||||
<TableHead className="text-right">Строк</TableHead>
|
||||
<TableHead className="text-right">Сумма</TableHead>
|
||||
<TableHead className="text-center">Primary</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.treeBreakdown.map((t, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{t.name}</TableCell>
|
||||
<TableCell className="text-right">{t.count}</TableCell>
|
||||
<TableCell className="text-right">{formatMoney(t.total)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{t.isPrimary ? <CheckCircle2 className="w-3.5 h-3.5 text-green-500 mx-auto" /> : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.gapRows.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-yellow-600">Нераспределённые строки (gap)</h4>
|
||||
<div className="max-h-60 overflow-auto border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => (
|
||||
<TableHead key={col} className="text-xs">{col}</TableHead>
|
||||
))}
|
||||
<TableHead className="text-right text-xs">Сумма</TableHead>
|
||||
<TableHead className="text-right text-xs">Кол-во</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.gapRows.slice(0, MAX_GAP_ROWS).map((r, i) => (
|
||||
<TableRow key={i}>
|
||||
{Object.keys(data.gapRows[0].columns).slice(0, 5).map(col => (
|
||||
<TableCell key={col} className="text-xs max-w-[150px] truncate">{r.columns[col] || "—"}</TableCell>
|
||||
))}
|
||||
<TableCell className="text-right text-xs">{formatMoney(r.sum)}</TableCell>
|
||||
<TableCell className="text-right text-xs">{r.count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data.gapRows.length > MAX_GAP_ROWS && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Показаны первые {MAX_GAP_ROWS} из {data.gapRows.length} строк
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditEntry({ audit, defaultOpen }: { audit: AuditResult; defaultOpen: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<button
|
||||
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />}
|
||||
<span className="text-sm whitespace-nowrap">{formatDateTime(audit.timestamp)}</span>
|
||||
<StatusBadge status={audit.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
|
||||
Доходы gap: {audit.income.gapCount} / {formatMoney(audit.income.gapTotal)} · Расходы gap: {audit.expense.gapCount} / {formatMoney(audit.expense.gapTotal)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[240px]">{audit.summary}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
<DirectionCard title="Доходы" data={audit.income} />
|
||||
<DirectionCard title="Расходы" data={audit.expense} />
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<FinanceLayout>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Аудит данных</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Проверка целостности распределения строк по категориям
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{latestAudit && <StatusBadge status={latestAudit.status} />}
|
||||
<Button
|
||||
onClick={() => runAudit.mutate()}
|
||||
disabled={running}
|
||||
className="gap-2"
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{running ? "Аудит выполняется…" : "Запустить аудит"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FinanceNav active="audit" onChange={() => {}} />
|
||||
|
||||
{historyLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestAudit && (
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Последний аудит: {formatDateTime(latestAudit.timestamp)}
|
||||
</span>
|
||||
<span className="text-sm">{latestAudit.summary}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!historyLoading && history.length === 0 && !latestAudit && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<RefreshCw className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Аудит ещё не проводился</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Нажмите «Запустить аудит» для первой проверки</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{history.map((h, i) => (
|
||||
<AuditEntry key={`${h.timestamp}-${i}`} audit={h} defaultOpen={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FinanceLayout>
|
||||
);
|
||||
}
|
||||
1171
client/src/pages/finance/calculations.tsx
Normal file
1171
client/src/pages/finance/calculations.tsx
Normal file
File diff suppressed because it is too large
Load Diff
651
client/src/pages/finance/debt.tsx
Normal file
651
client/src/pages/finance/debt.tsx
Normal file
@@ -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<string, string> = {
|
||||
cls0: "#22c55e",
|
||||
cls1_30: "#3b82f6",
|
||||
cls31_60: "#eab308",
|
||||
cls61_90: "#f97316",
|
||||
cls91_365: "#ef4444",
|
||||
cls365plus: "#991b1b",
|
||||
};
|
||||
|
||||
const OVERDUE_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 justify-between gap-1.5 min-w-[160px] max-w-[220px] text-xs font-normal"
|
||||
>
|
||||
<span className="truncate">{displayText}</span>
|
||||
<Filter className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2 max-h-[300px] overflow-y-auto" align="start">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{label}</div>
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-2 py-1 px-1 hover:bg-muted/50 rounded cursor-pointer text-xs">
|
||||
<Checkbox
|
||||
checked={selected.includes(opt)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) onChange([...selected, opt]);
|
||||
else onChange(selected.filter(s => s !== opt));
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
{selected.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="w-full mt-1 text-xs" onClick={() => onChange([])}>
|
||||
Сбросить
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalStackedChart({ title, data, height }: { title: string; data: any[]; height: number }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">{title}</h3>
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 10, right: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={140} />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [formatCurrency(value), OVERDUE_LABELS[name] || name]}
|
||||
labelFormatter={(label: string) => label}
|
||||
/>
|
||||
<Legend formatter={(value: string) => OVERDUE_LABELS[value] || value} wrapperStyle={{ fontSize: 10 }} />
|
||||
{CLS_KEYS.map(key => (
|
||||
<Bar key={key} dataKey={key} stackId="a" fill={OVERDUE_COLORS[key]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleBarChart({ title, data, height }: { title: string; data: { cls: string; total: number }[]; height: number }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
const clsColorMap: Record<string, string> = {
|
||||
"Не просрочено": "#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 (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">{title}</h3>
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 10, right: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="cls" tick={{ fontSize: 10 }} width={100} />
|
||||
<Tooltip formatter={(value: number) => [formatCurrency(value), "Сумма"]} />
|
||||
<Bar dataKey="total" radius={[0, 2, 2, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DebtTableSection({ analytics, page, setPage }: { analytics: DebtAnalytics; page: number; setPage: (p: number) => void }) {
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const { table } = analytics;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-foreground">Таблица расшифровки ДЗ, млн. ₽</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40">
|
||||
<th className="w-8 px-2"></th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Контрагент</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ, ₽</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ, ₽</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map((group) => {
|
||||
const isExpanded = expandedRow === group.kontragent;
|
||||
return (
|
||||
<Fragment key={group.kontragent}>
|
||||
<tr
|
||||
className="border-b border-border cursor-pointer hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setExpandedRow(isExpanded ? null : group.kontragent)}
|
||||
>
|
||||
<td className="px-2 text-center">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground inline" /> : <ChevronDown className="w-4 h-4 text-muted-foreground inline" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium max-w-[250px]"><CopyableCell text={group.kontragent} className="max-w-[250px] font-medium" /></td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap font-semibold">{formatCurrency(group.totalDebt)}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap text-red-500 font-medium">{formatCurrency(group.totalOverdue)}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-0">
|
||||
<div className="bg-muted/20">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-8 py-2 text-xs font-medium text-muted-foreground">Договор</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Сумма Договора</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Документ</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Оплачено</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ, ₽</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ, ₽</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Отсрочка по договору</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Дней просрочки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.rows.map((row, j) => (
|
||||
<tr key={j} className="border-b border-border last:border-0">
|
||||
<td className="px-8 py-2 text-muted-foreground text-xs max-w-[200px]"><CopyableCell text={row.dogovor || "—"} className="max-w-[200px]" /></td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs">{formatCurrency(row.summadokumenta)}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs max-w-[200px]"><CopyableCell text={row.dokumentraschetov || "—"} className="max-w-[200px]" /></td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs">{formatCurrency(row.summaoplacheno)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs font-medium">{formatCurrency(row.summadolga)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-red-500">{formatCurrency(row.summaprosrocheno)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-muted-foreground">{row.otsrochkapodogovoru}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap text-xs text-muted-foreground">{Math.round(row.dneiprosrocheno)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{table.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Стр. {table.page} из {table.totalPages} ({table.total} контрагентов)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= table.totalPages} onClick={() => setPage(page + 1)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DZTab() {
|
||||
const [period, setPeriod] = useState("");
|
||||
const [selectedKlasspy, setSelectedKlasspy] = useState<string[]>([]);
|
||||
const [selectedKontragent, setSelectedKontragent] = useState<string[]>([]);
|
||||
const [dogovorSearch, setDogovorSearch] = useState("");
|
||||
const [selectedManager, setSelectedManager] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
{isError && <DatabaseError />}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1, 2, 3].map(i => (
|
||||
<Card key={i}><CardContent className="p-4"><Skeleton className="h-4 w-32 mb-2" /><Skeleton className="h-7 w-24" /></CardContent></Card>
|
||||
))}
|
||||
</>
|
||||
) : analytics?.summary && (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Дата и время обновления
|
||||
</div>
|
||||
<div className="text-lg font-bold text-primary">
|
||||
{analytics.summary.updatedAt ? formatDateTime(analytics.summary.updatedAt) : "—"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Общая сумма ДЗ, тыс. ₽</p>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
{formatThousands(analytics.summary.totalDebt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Общая сумма ПДЗ, тыс. ₽</p>
|
||||
<p className="text-lg font-bold text-red-500">
|
||||
{formatThousands(analytics.summary.totalOverdue)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Период</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={period}
|
||||
onChange={e => { setPeriod(e.target.value); setPage(1); }}
|
||||
className="h-9 text-xs w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Класс просрочки</Label>
|
||||
<MultiSelect
|
||||
label="Класс просрочки"
|
||||
options={filters?.filters?.klassprosrochki || []}
|
||||
selected={selectedKlasspy}
|
||||
onChange={v => { setSelectedKlasspy(v); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Контрагент</Label>
|
||||
<MultiSelect
|
||||
label="Контрагент"
|
||||
options={filters?.filters?.kontragenty || []}
|
||||
selected={selectedKontragent}
|
||||
onChange={v => { setSelectedKontragent(v); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Договор</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={dogovorSearch}
|
||||
onChange={e => { setDogovorSearch(e.target.value); setPage(1); }}
|
||||
placeholder="Поиск..."
|
||||
className="h-9 text-xs pl-8 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Менеджер</Label>
|
||||
<MultiSelect
|
||||
label="Менеджер"
|
||||
options={filters?.filters?.otvetstvennii || []}
|
||||
selected={selectedManager}
|
||||
onChange={v => { setSelectedManager(v); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<Button variant="outline" size="sm" onClick={resetFilters} className="h-9">
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" />
|
||||
Сбросить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Card key={i}><CardContent className="p-4"><Skeleton className="h-[250px] w-full" /></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
) : analytics && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HorizontalStackedChart title="ДЗ по Контрагентам, млн. ₽" data={analytics.byKontragent} height={chartHeight} />
|
||||
<HorizontalStackedChart title="ДЗ по Менеджерам, млн. ₽" data={analytics.byManager} height={chartHeight} />
|
||||
<SimpleBarChart title="ДЗ по сроку просрочки, млн. ₽" data={analytics.byOverdueClass} height={chartHeight} />
|
||||
</div>
|
||||
|
||||
<DebtTableSection analytics={analytics} page={page} setPage={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Период (Динамика)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="h-9 text-xs w-[150px]"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="h-9 text-xs w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Контрагент</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9 min-w-[200px] justify-between text-xs font-normal">
|
||||
<span className="truncate">{kontragent || "Не выбрано"}</span>
|
||||
<Filter className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2 max-h-[300px] overflow-y-auto" align="start">
|
||||
<div
|
||||
className="py-1 px-2 hover:bg-muted/50 rounded cursor-pointer text-xs text-muted-foreground"
|
||||
onClick={() => setKontragent("")}
|
||||
>
|
||||
Все контрагенты
|
||||
</div>
|
||||
{(filters?.filters?.kontragenty || []).map((k) => (
|
||||
<div
|
||||
key={k}
|
||||
className={`py-1 px-2 hover:bg-muted/50 rounded cursor-pointer text-xs ${kontragent === k ? "bg-primary/10 text-primary font-medium" : ""}`}
|
||||
onClick={() => setKontragent(k)}
|
||||
>
|
||||
{k}
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Динамика ДЗ и ПДЗ, млн. ₽</h3>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-blue-500 rounded"></div>
|
||||
<span>ДЗ</span>
|
||||
<span className="font-bold text-blue-500">{formatCurrency(latestValues.dz)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-red-400 rounded"></div>
|
||||
<span>ПДЗ</span>
|
||||
<span className="font-bold text-red-400">{formatCurrency(latestValues.pdz)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{dynamicsLoading ? (
|
||||
<Skeleton className="h-[350px] w-full" />
|
||||
) : (
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatCompact} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
formatCurrency(value),
|
||||
name === "dz" ? "ДЗ" : "ПДЗ"
|
||||
]}
|
||||
labelFormatter={(label: string) => label}
|
||||
/>
|
||||
<Legend formatter={(value: string) => value === "dz" ? "ДЗ" : "ПДЗ"} />
|
||||
<Line type="monotone" dataKey="dz" stroke="#3b82f6" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="pdz" stroke="#f87171" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{kontragentData?.rows && kontragentData.rows.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground">Контрагент</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Сумма Договора</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Оплачено</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ОДЗ Руб.</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">ПДЗ Руб.</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-muted-foreground whitespace-nowrap">Дней просрочки</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kontragentData.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 max-w-[250px] font-medium"><CopyableCell text={row.kontragent} className="max-w-[250px] font-medium" /></td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">{formatCurrency(row.summadokumenta)}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap text-muted-foreground">{formatCurrency(row.summaoplacheno)}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap font-medium">{formatCurrency(row.summadolga)}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap text-red-500">{formatCurrency(row.summaprosrocheno)}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap text-muted-foreground">{formatNumber(Math.round(row.dneiprosrocheno))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DebtPage() {
|
||||
return (
|
||||
<FinanceLayout>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Дебиторская задолженность</h1>
|
||||
<p className="text-muted-foreground text-sm mt-0.5">Анализ дебиторской задолженности по контрагентам, менеджерам и срокам просрочки</p>
|
||||
</div>
|
||||
|
||||
<FinanceNav active="debt" onChange={() => {}} />
|
||||
|
||||
<Tabs defaultValue="dz">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dz">ДЗ</TabsTrigger>
|
||||
<TabsTrigger value="dynamics">Динамика</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dz" className="mt-4">
|
||||
<DZTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dynamics" className="mt-4">
|
||||
<DynamicsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</FinanceLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm">Источник данных (Google Sheets)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{statusLoading ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
) : loggedIn ? (
|
||||
<Badge variant="outline" className="text-xs gap-1.5 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
Авторизован: {googleStatus?.email || "Google"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs gap-1.5 py-1 text-muted-foreground">
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/50" />
|
||||
Google не авторизован
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{!loggedIn && (
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs gap-1.5" onClick={handleLogin}>
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
Войти через Google
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={handleSync}
|
||||
disabled={syncMutation.isPending || !loggedIn}
|
||||
>
|
||||
{syncMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Синхронизировать
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Участки ({areas.length})</p>
|
||||
{areasLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map(i => <Skeleton key={i} className="h-14 rounded-lg" />)}
|
||||
</div>
|
||||
) : areas.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Участки не добавлены</p>
|
||||
) : (
|
||||
areas.map(area => (
|
||||
<div key={area.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{area.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {area.spreadsheetId || "—"}
|
||||
{area.fioCol || area.bazaCol || area.kVydacheCol
|
||||
? ` · Столбцы: ФИО=${area.fioCol || "B"}, База=${area.bazaCol || "F"}, К выдаче=${area.kVydacheCol || "J"}`
|
||||
: ""}
|
||||
</p>
|
||||
{area.spreadsheetUrl && (
|
||||
<a
|
||||
href={area.spreadsheetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть таблицу
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => deleteArea.mutate(area.id)}
|
||||
disabled={deleteArea.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<p className="text-sm font-medium">Добавить участок</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Название участка</Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Например: Производство"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Ссылка на Google Таблицу</Label>
|
||||
<Input
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Столбцы (буква, опционально)</Label>
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">ФИО</Label>
|
||||
<Input value={newFioCol} onChange={e => setNewFioCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">База</Label>
|
||||
<Input value={newBazaCol} onChange={e => setNewBazaCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">К выдаче</Label>
|
||||
<Input value={newKVydacheCol} onChange={e => setNewKVydacheCol(e.target.value)} className="h-7 text-xs uppercase" maxLength={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleAddArea}
|
||||
disabled={createArea.isPending || !newName.trim() || !newUrl.trim()}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Main Page
|
||||
// =====================
|
||||
@@ -794,6 +1023,8 @@ export default function SalaryPage() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Данные из PostgreSQL</p>
|
||||
</div>
|
||||
|
||||
<GoogleSourceSection />
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className="h-8" data-testid="salary-tabs">
|
||||
<TabsTrigger value="of" className="text-xs h-7 px-3" data-testid="tab-of">ОФ</TabsTrigger>
|
||||
|
||||
305
package-lock.json
generated
305
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
566
server/finance/audit.ts
Normal file
566
server/finance/audit.ts
Normal file
@@ -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<string, string>; 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<string, any>, rule: CategoryRule): boolean {
|
||||
const val = row[rule.column];
|
||||
if (val === undefined) return false;
|
||||
switch (rule.operator) {
|
||||
case "equals": return val === rule.value;
|
||||
case "contains": return typeof val === "string" && matchTokens(val, rule.value);
|
||||
case "not_equals": return val !== rule.value;
|
||||
case "greater_than": return Number(val) > Number(rule.value);
|
||||
case "less_than": return Number(val) < Number(rule.value);
|
||||
case "equal_number": return Number(val) === Number(rule.value);
|
||||
default: return val === rule.value;
|
||||
}
|
||||
}
|
||||
|
||||
function rowMatchesRules(row: Record<string, any>, rules: CategoryRule[], logic: "and" | "or" = "and"): boolean {
|
||||
if (!rules || rules.length === 0) return false;
|
||||
if (logic === "or") return rules.some(r => rowMatchesRule(row, r));
|
||||
return rules.every(r => rowMatchesRule(row, r));
|
||||
}
|
||||
|
||||
function collectRuleColumns(cats: Category[]): Set<string> {
|
||||
const cols = new Set<string>();
|
||||
for (const cat of cats) {
|
||||
for (const rule of (cat.rules || [])) cols.add(rule.column);
|
||||
if (cat.children?.length) {
|
||||
for (const col of collectRuleColumns(cat.children)) cols.add(col);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function rowMatchesAutoPath(row: Record<string, any>, autoPath: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }): boolean {
|
||||
for (let i = 0; i < autoPath.ruleSets.length - 1; i++) {
|
||||
if (!rowMatchesRules(row, autoPath.ruleSets[i])) return false;
|
||||
}
|
||||
return rowMatchesRules(row, autoPath.ruleSets[autoPath.ruleSets.length - 1], autoPath.logic);
|
||||
}
|
||||
|
||||
function computeNodeFromAgg(
|
||||
cat: Category,
|
||||
siblings: Category[],
|
||||
ancestorRuleSets: CategoryRule[][],
|
||||
ancestorIds: Set<string>,
|
||||
allFlat: Category[],
|
||||
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
|
||||
aggRows: Record<string, any>[],
|
||||
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
|
||||
): { total: number; count: number; excluded: boolean; children: any[] } {
|
||||
const catLogic = cat.ruleLogic || "and";
|
||||
const idx = siblings.indexOf(cat);
|
||||
const laterSibs = siblings.slice(idx + 1);
|
||||
|
||||
const allRuleSets = [...ancestorRuleSets, cat.rules];
|
||||
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (const row of aggRows) {
|
||||
const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs));
|
||||
if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) continue;
|
||||
if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) continue;
|
||||
if (!cat.autoDetected && exclPaths.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) continue;
|
||||
if (!cat.autoDetected && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) continue;
|
||||
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) continue;
|
||||
total += row.total;
|
||||
count += row.cnt;
|
||||
}
|
||||
|
||||
const children: any[] = [];
|
||||
if (cat.children?.length) {
|
||||
const childAncestorIds = new Set(ancestorIds);
|
||||
childAncestorIds.add(cat.id);
|
||||
for (const child of cat.children) {
|
||||
children.push(computeNodeFromAgg(child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, aggRows, exclPaths));
|
||||
}
|
||||
}
|
||||
|
||||
return { total, count, excluded: !!cat.excluded, children };
|
||||
}
|
||||
|
||||
function nodeSubtreeTotal(n: { total: number; excluded: boolean; children: any[] }): number {
|
||||
if (n.excluded) {
|
||||
let t = 0;
|
||||
for (const ch of n.children) t += nodeSubtreeTotal(ch);
|
||||
return t;
|
||||
}
|
||||
let t = n.total;
|
||||
for (const ch of n.children) t += nodeSubtreeTotal(ch);
|
||||
return t;
|
||||
}
|
||||
|
||||
function nodeSubtreeCount(n: { count: number; excluded: boolean; children: any[] }): number {
|
||||
if (n.excluded) {
|
||||
let c = 0;
|
||||
for (const ch of n.children) c += nodeSubtreeCount(ch);
|
||||
return c;
|
||||
}
|
||||
let c = n.count;
|
||||
for (const ch of n.children) c += nodeSubtreeCount(ch);
|
||||
return c;
|
||||
}
|
||||
|
||||
function nodeExcludedTotal(n: { total: number; excluded: boolean; children: any[] }): number {
|
||||
if (n.excluded) {
|
||||
let t = n.total;
|
||||
for (const ch of n.children) t += nodeExcludedTotal(ch);
|
||||
return t;
|
||||
}
|
||||
let t = 0;
|
||||
for (const ch of n.children) t += nodeExcludedTotal(ch);
|
||||
return t;
|
||||
}
|
||||
|
||||
function nodeExcludedCount(n: { count: number; excluded: boolean; children: any[] }): number {
|
||||
if (n.excluded) {
|
||||
let c = n.count;
|
||||
for (const ch of n.children) c += nodeExcludedCount(ch);
|
||||
return c;
|
||||
}
|
||||
let c = 0;
|
||||
for (const ch of n.children) c += nodeExcludedCount(ch);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function auditDirection(
|
||||
table: string,
|
||||
sumColumn: string,
|
||||
cats: Category[],
|
||||
autoConfig: AutoDetectConfig,
|
||||
allowedColumns: string[]
|
||||
): Promise<DirectionAudit> {
|
||||
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<string, any> = { total: Number(r.total), cnt: Number(r.cnt) };
|
||||
for (const col of groupCols) row[col] = r[col] ?? "";
|
||||
return row;
|
||||
});
|
||||
|
||||
let dbTotal = 0, dbCount = 0;
|
||||
for (const row of aggRows) { dbTotal += row.total; dbCount += row.cnt; }
|
||||
|
||||
const primaryTreeIds = new Set(autoConfig.trees.filter(t => t.isPrimary).map(t => t.id));
|
||||
|
||||
const groupedByTree = new Map<string, Category[]>();
|
||||
for (const cat of cats) {
|
||||
const tid = cat.treeId || "__manual__";
|
||||
if (!groupedByTree.has(tid)) groupedByTree.set(tid, []);
|
||||
groupedByTree.get(tid)!.push(cat);
|
||||
}
|
||||
|
||||
const autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
|
||||
const excludedPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = [];
|
||||
function collectAllPaths(categories: Category[], ancestors: CategoryRule[][]) {
|
||||
for (const cat of categories) {
|
||||
const path = [...ancestors, cat.rules || []];
|
||||
if (cat.excluded) {
|
||||
excludedPaths.push({ id: cat.id, ruleSets: path, logic: (cat.ruleLogic || "and") });
|
||||
} else if (cat.autoDetected && cat.rules?.length) {
|
||||
autoPaths.push({ ruleSets: path, logic: (cat.ruleLogic || "and") });
|
||||
}
|
||||
if (cat.children?.length) {
|
||||
collectAllPaths(cat.children, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectAllPaths(cats, []);
|
||||
|
||||
const treeBreakdown: { name: string; total: number; count: number; isPrimary: boolean }[] = [];
|
||||
let allTreeTotal = 0, allTreeCount = 0;
|
||||
let allExclTotal = 0, allExclCount = 0;
|
||||
|
||||
for (const [tid, treeCats] of groupedByTree) {
|
||||
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||
const treeName = tid === "__manual__"
|
||||
? "Ручные категории"
|
||||
: autoConfig.trees.find(t => t.id === tid)?.name || tid;
|
||||
|
||||
const nodes: any[] = [];
|
||||
for (const cat of treeCats) {
|
||||
nodes.push(computeNodeFromAgg(cat, treeCats, [], new Set(), allFlat, autoPaths, aggRows, excludedPaths));
|
||||
}
|
||||
|
||||
const tTotal = nodes.reduce((s, n) => s + nodeSubtreeTotal(n), 0);
|
||||
const tCount = nodes.reduce((s, n) => s + nodeSubtreeCount(n), 0);
|
||||
const eTotal = nodes.reduce((s, n) => s + nodeExcludedTotal(n), 0);
|
||||
const eCount = nodes.reduce((s, n) => s + nodeExcludedCount(n), 0);
|
||||
|
||||
treeBreakdown.push({ name: treeName, total: tTotal, count: tCount, isPrimary });
|
||||
|
||||
if (isPrimary) {
|
||||
allTreeTotal += tTotal;
|
||||
allTreeCount += tCount;
|
||||
allExclTotal += eTotal;
|
||||
allExclCount += eCount;
|
||||
}
|
||||
}
|
||||
|
||||
function isRowMatchedByTreeAudit(row: Record<string, any>, treeCats: Category[]): boolean {
|
||||
for (const cat of treeCats) {
|
||||
const catLogic = cat.ruleLogic || "and";
|
||||
if (!cat.rules?.length || !rowMatchesRules(row, cat.rules, catLogic)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let uncatTotal = 0, uncatCount = 0;
|
||||
const gapRows: { columns: Record<string, string>; sum: number; count: number }[] = [];
|
||||
|
||||
for (const row of aggRows) {
|
||||
let matched = false;
|
||||
for (const [tid, treeCats] of groupedByTree) {
|
||||
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||
if (!isPrimary) continue;
|
||||
if (isRowMatchedByTreeAudit(row, treeCats)) { matched = true; break; }
|
||||
}
|
||||
if (!matched) {
|
||||
uncatTotal += row.total;
|
||||
uncatCount += row.cnt;
|
||||
}
|
||||
}
|
||||
|
||||
const accountedTotal = allTreeTotal + allExclTotal + uncatTotal;
|
||||
const accountedCount = allTreeCount + allExclCount + uncatCount;
|
||||
const gapTotal = dbTotal - accountedTotal;
|
||||
const gapCount = dbCount - accountedCount;
|
||||
|
||||
if (Math.abs(gapCount) > 0) {
|
||||
for (const row of aggRows) {
|
||||
let inTree = false;
|
||||
let inUncat = true;
|
||||
for (const [tid, treeCats] of groupedByTree) {
|
||||
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||
if (!isPrimary) continue;
|
||||
if (isRowMatchedByTreeAudit(row, treeCats)) { inUncat = false; break; }
|
||||
}
|
||||
if (!inUncat) {
|
||||
for (const [tid, treeCats] of groupedByTree) {
|
||||
const isPrimary = tid === "__manual__" || primaryTreeIds.has(tid);
|
||||
if (!isPrimary) continue;
|
||||
for (const cat of treeCats) {
|
||||
if (isRowCapturedByNode(row, cat, treeCats, [], new Set(), allFlat, autoPaths, excludedPaths)) {
|
||||
inTree = true; break;
|
||||
}
|
||||
}
|
||||
if (inTree) break;
|
||||
}
|
||||
}
|
||||
if (!inUncat && !inTree) {
|
||||
const cols: Record<string, string> = {};
|
||||
for (const col of groupCols) cols[col] = String(row[col] || "");
|
||||
gapRows.push({ columns: cols, sum: row.total, count: row.cnt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dbTotal, dbCount,
|
||||
treeTotal: allTreeTotal, treeCount: allTreeCount,
|
||||
excludedTotal: allExclTotal, excludedCount: allExclCount,
|
||||
uncategorizedTotal: uncatTotal, uncategorizedCount: uncatCount,
|
||||
gapTotal: Math.round(gapTotal * 100) / 100,
|
||||
gapCount,
|
||||
gapRows: gapRows.slice(0, 50),
|
||||
treeBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
function isRowCapturedByNode(
|
||||
row: Record<string, any>,
|
||||
cat: Category,
|
||||
siblings: Category[],
|
||||
ancestorRuleSets: CategoryRule[][],
|
||||
ancestorIds: Set<string>,
|
||||
allFlat: Category[],
|
||||
autoPaths: { ruleSets: CategoryRule[][]; logic: ("and" | "or") }[],
|
||||
exclPaths: { id: string; ruleSets: CategoryRule[][]; logic: ("and" | "or") }[] = []
|
||||
): boolean {
|
||||
const catLogic = cat.ruleLogic || "and";
|
||||
const idx = siblings.indexOf(cat);
|
||||
const laterSibs = siblings.slice(idx + 1);
|
||||
|
||||
const ancestorMatch = ancestorRuleSets.every(rs => rowMatchesRules(row, rs));
|
||||
if (!ancestorMatch || !rowMatchesRules(row, cat.rules, catLogic)) return false;
|
||||
if (laterSibs.some(sib => rowMatchesRules(row, sib.rules, sib.ruleLogic || "and"))) return false;
|
||||
if (!cat.autoDetected && exclPaths.some(ep => ep.id !== cat.id && !ancestorIds.has(ep.id) && ep.ruleSets.every((rs, i) => i < ep.ruleSets.length - 1 ? rowMatchesRules(row, rs) : rowMatchesRules(row, rs, ep.logic)))) return false;
|
||||
if (!cat.autoDetected && autoPaths.some(ap => rowMatchesAutoPath(row, ap))) return false;
|
||||
|
||||
if (cat.children?.length && cat.children.some(child => (child.rules?.length || 0) > 0 && rowMatchesRules(row, child.rules, child.ruleLogic || "and"))) {
|
||||
const allRuleSets = [...ancestorRuleSets, cat.rules];
|
||||
const childAncestorIds = new Set(ancestorIds);
|
||||
childAncestorIds.add(cat.id);
|
||||
for (const child of cat.children) {
|
||||
if (isRowCapturedByNode(row, child, cat.children, allRuleSets, childAncestorIds, allFlat, autoPaths, exclPaths)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const INCOME_COLUMNS = [
|
||||
"statya", "zakazchik", "dochka", "vnuchka", "kommentarii", "stavkands",
|
||||
"naznachenieplatezha", "otvetstvenniipokontraktu", "dogovorkontragenta",
|
||||
"dogovorzakazchika", "statyadop", "schetnaoplatu", "nomervhodyaschegodokumenta",
|
||||
];
|
||||
|
||||
const EXPENSE_COLUMNS = [
|
||||
"statya", "dochka", "vnuchka", "zakazchik", "naznachenieplatezha",
|
||||
"statyadop", "kommentarii", "otvetstvenniipokontraktu", "dogovorzakazchika",
|
||||
"dogovorkontragenta",
|
||||
];
|
||||
|
||||
let auditRunning = false;
|
||||
|
||||
export async function runIntegrityAudit(): Promise<AuditResult> {
|
||||
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<void> {
|
||||
const today = mskToday();
|
||||
if (lastRunDay === today) return; // сегодня уже запускался
|
||||
if (mskHourNow() < AUDIT_HOUR_MSK) return; // ещё не наступило 03:00 по МСК
|
||||
lastRunDay = today;
|
||||
try {
|
||||
await runIntegrityAudit();
|
||||
} catch (err) {
|
||||
console.error("[AUDIT-AGENT] Ошибка при выполнении аудита:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAuditScheduler(): void {
|
||||
if (schedulerStarted) return;
|
||||
schedulerStarted = true;
|
||||
|
||||
setInterval(() => {
|
||||
tick().catch((err) => console.error("[AUDIT-AGENT] Tick error:", err));
|
||||
}, TICK_MS);
|
||||
console.log("[AUDIT-AGENT] Планировщик запущен (ежедневно в 03:00 по МСК)");
|
||||
|
||||
// Первый запуск через 10 секунд после старта сервера
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await runIntegrityAudit();
|
||||
lastRunDay = new Date(new Date(result.timestamp).getTime() + MSK_OFFSET_MS).toISOString().slice(0, 10);
|
||||
} catch (err) {
|
||||
console.error("[AUDIT-AGENT] Ошибка при начальном аудите:", err);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
272
server/finance/calculations.ts
Normal file
272
server/finance/calculations.ts
Normal file
@@ -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<string>();
|
||||
[...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<string, { node: IncomeCategoryFile; parentId: string | null }> {
|
||||
const map = new Map<string, { node: IncomeCategoryFile; parentId: string | null }>();
|
||||
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<string, { node: IncomeCategoryFile; parentId: string | null }>, 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<string, number>();
|
||||
const chartSet = new Set((catFilter || []));
|
||||
const monthlyMap = new Map<string, { incVat: number; incVatAmt: number; incNoVat: number; inc: number }>();
|
||||
|
||||
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<string>();
|
||||
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<string, number>();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
36
server/finance/category-matching.ts
Normal file
36
server/finance/category-matching.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Матчинг строк финансовых таблиц по правилам категорий (деревья).
|
||||
// Переиспользуется routes.ts (tree-summary, категории) и calculations.ts (налог на прибыль).
|
||||
|
||||
export function matchRule(row: Record<string, any>, 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<string, any>, 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;
|
||||
}
|
||||
@@ -75,6 +75,12 @@ export async function query<T extends QueryResultRow = any>(sql: string, params?
|
||||
return p.query<T>(sql, params);
|
||||
}
|
||||
|
||||
// Клиент для ручных транзакций (BEGIN/COMMIT/ROLLBACK) — см. google-sheets.ts (синхронизация зарплат)
|
||||
export async function getPoolClient(): Promise<PoolClient> {
|
||||
const p = getPool();
|
||||
return p.connect();
|
||||
}
|
||||
|
||||
export async function testConnection(config: DbConfig): Promise<{ ok: boolean; message: string }> {
|
||||
const testPool = createPool(config);
|
||||
try {
|
||||
|
||||
318
server/finance/debt.ts
Normal file
318
server/finance/debt.ts
Normal file
@@ -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<string> {
|
||||
const cacheKey = "debt:__max_period__";
|
||||
const cached = cache.get<string>(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, unknown>): 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<string, unknown>);
|
||||
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<string, any>();
|
||||
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<string, any>();
|
||||
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<string, { kontragent: string; totalDebt: number; totalOverdue: number; rows: any[] }>();
|
||||
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<string, unknown>);
|
||||
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<string, unknown>);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
494
server/finance/google-sheets.ts
Normal file
494
server/finance/google-sheets.ts
Normal file
@@ -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<string[]> {
|
||||
const auth = await getAuthenticatedClient();
|
||||
const sheets = google.sheets({ version: "v4", auth });
|
||||
const res = await sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties.title" });
|
||||
return (res.data.sheets || []).map((s: any) => s.properties?.title || "").filter(Boolean);
|
||||
}
|
||||
|
||||
async function getSheetData(spreadsheetId: string, range: string): Promise<any[][]> {
|
||||
const auth = await getAuthenticatedClient();
|
||||
const sheets = google.sheets({ version: "v4", auth });
|
||||
const res = await sheets.spreadsheets.values.get({ spreadsheetId, range });
|
||||
return res.data.values || [];
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Участки (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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 4, baseDelayMs = 1000): Promise<T> {
|
||||
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<any[]> {
|
||||
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<boolean> {
|
||||
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<string>();
|
||||
|
||||
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 || "неизвестная ошибка") });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<string, any>, 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<string, any>, 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<void> {
|
||||
registerAutoDetectRoutes(app);
|
||||
registerTimeseriesRoutes(app);
|
||||
await registerSalaryRoutes(app);
|
||||
registerDebtRoutes(app);
|
||||
registerCalculationRoutes(app);
|
||||
registerAuditRoutes(app);
|
||||
registerGoogleSheetsRoutes(app);
|
||||
startAuditScheduler();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface TableConfig {
|
||||
expenseTable: string;
|
||||
incomeSumColumn: string;
|
||||
expenseSumColumn: string;
|
||||
debtTable?: string;
|
||||
columnLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ export function getTableConfig(): TableConfig {
|
||||
expenseTable: "expenses",
|
||||
incomeSumColumn: "summa",
|
||||
expenseSumColumn: "summadokumenta",
|
||||
debtTable: "vw_debt_active",
|
||||
columnLabels: {
|
||||
data: "Дата",
|
||||
summa: "Сумма",
|
||||
|
||||
Reference in New Issue
Block a user