From de9bbfe17a3ef61b014e69ae6e895625769baf57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 29 Sep 2026 07:49:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=94=D0=A1=20=D0=B2=20DI2:=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=A0=D0=B0=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=8B=20=E2=86=92=20=D0=9D=D0=94=D0=A1=C2=BB?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B4=D0=B5=D0=BA=D0=BB=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=83=D1=8E=20=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=83=201=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Новый server/finance-di2/nds.routes.ts: GET /api/di2/nds/summary|quarters|input-balance|sync-status|contract-markers (только отображение nds_monthly/nds_input_balance/nds_sync_log/nds_contract_marker, без расчёта НДС) - VatTab переписан: KPI, динамика (месяц с накопительным итогом квартала / квартал), по ставкам, stacked по типам операций, доля авансов, резерв вычетов (19 счёт), баннер свежести, таблица «деньги есть — отгрузки нет» (RED_NO_SHIPMENT > 1 млн) - Старый GET /api/calculations/vat (вычисление 20/120 из платежей) удалён --- client-di2/src/pages/calculations.tsx | 415 +++++++++++++++++--------- server/finance-di2/index.ts | 2 + server/finance-di2/nds.routes.ts | 222 ++++++++++++++ server/finance-di2/routes.ts | 88 ------ 4 files changed, 499 insertions(+), 228 deletions(-) create mode 100644 server/finance-di2/nds.routes.ts diff --git a/client-di2/src/pages/calculations.tsx b/client-di2/src/pages/calculations.tsx index 87199db..3e1222e 100644 --- a/client-di2/src/pages/calculations.tsx +++ b/client-di2/src/pages/calculations.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@/lib/queryClient"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -10,9 +10,8 @@ import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { DatabaseError } from "@/components/database-error"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { CalendarDays, X, TrendingUp, TrendingDown, DollarSign, Receipt, Calculator, Percent, Settings, ChevronRight, ChevronDown, GitBranch, Plus, Trash2, ExternalLink, Link2, Search } from "lucide-react"; +import { CalendarDays, X, TrendingUp, TrendingDown, DollarSign, Receipt, Calculator, Settings, ChevronRight, ChevronDown, GitBranch, Plus, Trash2, ExternalLink, Link2, Search, AlertTriangle } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Line, ComposedChart } from "recharts"; import { formatCurrency } from "@/lib/format"; @@ -288,24 +287,8 @@ interface ChartHighlight { type: "income" | "expense"; } -const MONTH_NAMES = [ - "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", - "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", -]; - type GroupBy = "month" | "quarter" | "year"; -function rateOrder(rate: string): number { - const lower = rate.toLowerCase().trim(); - if (lower === "без ндс") return -1; - const m = rate.match(/(\d+)/); - return m ? Number(m[1]) : 0; -} - -function sortByRate(arr: T[]): T[] { - return [...arr].sort((a, b) => rateOrder(a.rate) - rateOrder(b.rate)); -} - function formatPeriodLabel(period: string, groupBy: GroupBy): string { try { if (groupBy === "year") return period; @@ -360,23 +343,6 @@ function groupMonthlyData( return [...groups.values()]; } -function addCumulativeVat( - data: { period: string; incomeVat: number; expenseVat: number; toPay: number }[], - startMonth: number, -): (typeof data[number] & { cumToPay: number })[] { - let cumulative = 0; - let currentQuarter = ""; - return data.map(row => { - const q = getQuarterKey(row.period, startMonth); - if (q !== currentQuarter) { - cumulative = 0; - currentQuarter = q; - } - cumulative += row.toPay; - return { ...row, cumToPay: Math.round(cumulative * 100) / 100 }; - }); -} - export default function Calculations() { const [activeTab, setActiveTab] = useState<"vat" | "profit" | "settings">( () => (localStorage.getItem("omegaprom_calc_tab") as "vat" | "profit" | "settings") || "vat" @@ -439,13 +405,42 @@ export default function Calculations() { const queryString = useMemo(() => new URLSearchParams(dateParams).toString(), [dateParams]); - const vatQuery = useQuery({ - queryKey: ["/api/calculations/vat", dateParams], - queryFn: async () => { - const r = await fetch(`/api/calculations/vat?${queryString}`); - if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`); - return r.json(); - }, + const ndsFetch = async (url: string) => { + const r = await fetch(url); + if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`); + return r.json(); + }; + + // Вкладка «НДС» отображает декларационную витрину 1С (nds_monthly), никаких расчётов на клиенте + const ndsSummaryQuery = useQuery({ + queryKey: ["/api/di2/nds/summary", dateParams], + queryFn: () => ndsFetch(`/api/di2/nds/summary?${queryString}`), + enabled: activeTab === "vat", + }); + + const ndsQuartersQuery = useQuery({ + queryKey: ["/api/di2/nds/quarters"], + queryFn: () => ndsFetch("/api/di2/nds/quarters"), + enabled: activeTab === "vat", + }); + + const ndsBalanceQuery = useQuery({ + queryKey: ["/api/di2/nds/input-balance"], + queryFn: () => ndsFetch("/api/di2/nds/input-balance"), + enabled: activeTab === "vat", + }); + + const ndsSyncQuery = useQuery({ + queryKey: ["/api/di2/nds/sync-status"], + queryFn: () => ndsFetch("/api/di2/nds/sync-status"), + enabled: activeTab === "vat", + refetchInterval: 5 * 60 * 1000, + }); + + // Маркер «деньги vs отгрузка» по договорам: красные флаги квартала (главный запрос спецификации) + const ndsMarkersQuery = useQuery({ + queryKey: ["/api/di2/nds/contract-markers"], + queryFn: () => ndsFetch("/api/di2/nds/contract-markers?marker=RED_NO_SHIPMENT&minPaid=1000000"), enabled: activeTab === "vat", }); @@ -480,9 +475,9 @@ export default function Calculations() { const resetDates = () => { setDateFrom(""); setDateTo(""); }; const hasDates = dateFrom || dateTo; - const isLoading = activeTab === "vat" ? vatQuery.isLoading : activeTab === "profit" ? profitQuery.isLoading : false; - const isError = activeTab === "vat" ? vatQuery.isError : activeTab === "profit" ? profitQuery.isError : false; - const error = activeTab === "vat" ? vatQuery.error : activeTab === "profit" ? profitQuery.error : null; + const isLoading = activeTab === "vat" ? ndsSummaryQuery.isLoading : activeTab === "profit" ? profitQuery.isLoading : false; + const isError = activeTab === "vat" ? ndsSummaryQuery.isError : activeTab === "profit" ? profitQuery.isError : false; + const error = activeTab === "vat" ? ndsSummaryQuery.error : activeTab === "profit" ? profitQuery.error : null; return (
@@ -559,7 +554,15 @@ export default function Calculations() {
)} - {activeTab === "vat" && vatQuery.data && !isLoading && } + {activeTab === "vat" && ndsSummaryQuery.data && !isLoading && ( + + )} {activeTab === "profit" && profitQuery.data && !isLoading && ( ( - () => (localStorage.getItem("omegaprom_vat_groupby") as GroupBy) || "month" +function formatQuarterLabel(q: string): string { + // q = '2026-01-01' (date_trunc quarter) + const quarter = Math.floor((Number(q.slice(5, 7)) - 1) / 3) + 1; + return `${q.slice(0, 4)} Q${quarter}`; +} + +function ChartTooltip({ active, payload, label, formatValue }: any) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry: any) => ( +
+
+ {entry.name} + + {formatValue ? formatValue(entry.value) : formatCurrency(entry.value)} + +
+ ))} +
); - const [startMonth, setStartMonth] = useState( - () => Number(localStorage.getItem("omegaprom_vat_start_month") || "1") +} + +// Маркеры «деньги vs отгрузка» (словарь спецификации §2.3) +const MARKER_META: Record = { + RED_NO_SHIPMENT: { label: "Деньги есть — отгрузки нет", color: "#e11d48" }, + YELLOW_PAID_GT_SHIPPED: { label: "Оплачено больше отгруженного", color: "#f59e0b" }, + BLUE_SHIPPED_GT_PAID: { label: "Отгружено больше оплаченного", color: "#2563eb" }, + WHITE_ADVANCE_OPEN: { label: "Расчёты сошлись, аванс не зачтён", color: "#94a3b8" }, + GREEN_OK: { label: "Сошлось", color: "#16a34a" }, +}; + +function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarters: any[]; balance: any[]; sync: any; markers: any }) { + const [groupBy, setGroupBy] = useState<"month" | "quarter">( + () => (localStorage.getItem("omegaprom_vat_groupby") === "quarter" ? "quarter" : "month") ); - function changeGroupBy(v: GroupBy) { + function changeGroupBy(v: "month" | "quarter") { setGroupBy(v); localStorage.setItem("omegaprom_vat_groupby", v); } - function changeStartMonth(v: string) { - const m = Number(v); - setStartMonth(m); - localStorage.setItem("omegaprom_vat_start_month", String(m)); - } + // Все суммы приходят готовыми из витрины: помесячно (с накопительным итогом квартала) и поквартально + const chartData = groupBy === "month" ? (data.monthly ?? []) : quarters; + const periodLabel = (v: string) => (groupBy === "month" ? formatPeriodLabel(v, "month") : formatQuarterLabel(v)); - const chartData = useMemo(() => { - if (!data.monthly?.length) return []; - const grouped = groupMonthlyData( - data.monthly, - groupBy, - startMonth, - ["incomeVat", "expenseVat", "toPay"] as any[], - ); - if (groupBy === "month") { - return addCumulativeVat(grouped as any, startMonth); + // Поворот (period, event, vat) в столбцы для stacked-диаграммы — чистое отображение, без расчётов + const eventNames = useMemo(() => { + const names = new Set(); + for (const r of data.events ?? []) names.add(r.event); + return [...names]; + }, [data.events]); + const eventsChart = useMemo(() => { + const byPeriod = new Map(); + for (const r of data.events ?? []) { + if (!byPeriod.has(r.period)) byPeriod.set(r.period, { period: r.period }); + byPeriod.get(r.period)[r.event] = r.vat; } - return grouped.map((r: any) => ({ ...r, cumToPay: undefined })); - }, [data.monthly, groupBy, startMonth]); + return [...byPeriod.values()].sort((a, b) => a.period.localeCompare(b.period)); + }, [data.events]); + + const salesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "sales"); + const purchasesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "purchases"); + const totals = data.totals ?? { accrued: 0, deducted: 0, toPay: 0 }; + const redRows: any[] = markers?.rows ?? []; + const markerCounts: any[] = markers?.byMarker ?? []; return (
@@ -620,9 +658,9 @@ function VatTab({ data }: { data: any }) {
- НДС с доходов + Начислено
-

{formatCurrency(data.totalIncomeVat)}

+

{formatCurrency(totals.accrued)}

@@ -631,9 +669,9 @@ function VatTab({ data }: { data: any }) {
- НДС с расходов + Вычет
-

{formatCurrency(data.totalExpenseVat)}

+

{formatCurrency(totals.deducted)}

@@ -644,46 +682,45 @@ function VatTab({ data }: { data: any }) { К уплате -

= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay"> - {formatCurrency(data.toPay)} +

= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay"> + {formatCurrency(totals.toPay)}

+
+ {sync?.stale && ( +
+ + Данные витрины устарели — синхронизация с 1С не выполнялась более 36 часов +
+ )} +

+ Источник: декларационная витрина 1С (книга продаж, книга покупок, счёт 19) + {sync?.startedAt ? ` · синхронизировано ${new Date(sync.startedAt).toLocaleString("ru-RU")}` : ""} + {data.dateFrom && data.dateTo ? ` · период: ${data.dateFrom} — ${data.dateTo}` : ""} +

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

{formatPeriodLabel(label, groupBy)}

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

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

)} )} + {eventsChart.length > 0 && ( + + + Начисления по типам операций + + + + + + formatPeriodLabel(v, "month")} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} /> + + } /> + + {eventNames.map((name, i) => ( + + ))} + + +

+ Всплеск «Получен аванс» — уплата НДС с аванса до отгрузки; при отгрузке авансовая строка сторнируется +

+
+
+ )} + + {markers && ( + + +
+ + Контроль квартала: деньги есть — отгрузки нет + +
+ {markerCounts.map((m: any) => { + const meta = MARKER_META[m.marker] ?? { label: m.marker, color: "#94a3b8" }; + return ( + + + {m.count} + + ); + })} +
+
+
+ + {redRows.length > 0 ? ( +
+
+ Контрагент + Договор + Оплачено с начала года + Аванс на 62.02 +
+ {redRows.map((r: any, i: number) => ( +
+ {r.counterparty} + + {r.contract || "— договор не заполнен —"} + + {formatCurrency(r.paid)} + {formatCurrency(r.advanceBalance)} +
+ ))} +
+ ) : ( +

Крупных необеспеченных авансов (более 1 млн ₽ без отгрузки) нет

+ )} +

+ Договоры со сверкой: {markerCounts.reduce((s: number, m: any) => s + m.count, 0)}. НДС с этих авансов уже попал в «к уплате», а выручка и зачёт — в будущем. + Пустой договор — в платежах/реализациях договор не выбран (самостоятельная проблема учёта). +

+
+
+ )} +
- По ставкам (доходы) + По ставкам (начислено)
- {sortByRate(data.incomeByRate).map((r: any) => ( + {salesByRate.map((r: any) => (
-
- {r.rate} - ({r.count} док.) -
+ {r.rate}
- {formatCurrency(r.total)} + {formatCurrency(r.totalNoVat)} НДС: {formatCurrency(r.vat)} @@ -760,18 +854,15 @@ function VatTab({ data }: { data: any }) { - По ставкам (расходы) + По ставкам (вычет)
- {sortByRate(data.expenseByRate).map((r: any) => ( + {purchasesByRate.map((r: any) => (
-
- {r.rate} - ({r.count} док.) -
+ {r.rate}
- {formatCurrency(r.total)} + {formatCurrency(r.totalNoVat)} НДС: {formatCurrency(r.vat)} @@ -782,6 +873,50 @@ function VatTab({ data }: { data: any }) {
+ +
+ {(data.advances ?? []).length > 0 && ( + + + Доля авансов в начислениях + + + + + + formatPeriodLabel(v, "month")} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} /> + + `${v} %`} />} /> + + + + + + + + )} + {balance.length > 0 && ( + + + Резерв будущих вычетов (счёт 19) + + + + + + `${v.slice(8, 10)}.${v.slice(5, 7)}`} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} /> + + } /> + + + +

+ Входящий НДС, предъявленный поставщиками, но ещё не принятый к вычету +

+
+
+ )} +
); } diff --git a/server/finance-di2/index.ts b/server/finance-di2/index.ts index 75aca97..15a5968 100644 --- a/server/finance-di2/index.ts +++ b/server/finance-di2/index.ts @@ -5,6 +5,7 @@ import type { Express } from "express"; import express from "express"; import { authenticateToken, requirePermission } from "../middleware/auth.middleware"; import { registerRoutes } from "./routes"; +import { registerNdsRoutes } from "./nds.routes"; import { startAuditScheduler } from "./audit-scheduler"; const DI2_PREFIXES = [ @@ -51,6 +52,7 @@ export async function registerDi2Routes(app: Express): Promise { }); await registerRoutes(null as any, router as any); + registerNdsRoutes(router as any); app.use(router); diff --git a/server/finance-di2/nds.routes.ts b/server/finance-di2/nds.routes.ts new file mode 100644 index 0000000..f9d9b94 --- /dev/null +++ b/server/finance-di2/nds.routes.ts @@ -0,0 +1,222 @@ +// Эндпоинты витрины НДС (1С → PostgreSQL). +// Источник: декларационная витрина nds_monthly / nds_input_balance / nds_sync_log +// (книга продаж / книга покупок / счёт 19). Сервер только агрегирует готовые суммы, +// расчёта НДС здесь нет — см. спецификацию «Витрина НДС — спецификация для CRM». + +import type { Express, Response } from "express"; +import { query, isConnectionError } from "./db-client"; +import { cachedQuery, stableKey } from "./cache"; + +const TTL_NDS = 15 * 60 * 1000; +const TTL_SYNC = 2 * 60 * 1000; + +function handleApiError(res: Response, err: unknown, fallbackMessage = "Ошибка базы данных") { + console.error(err); + if (isConnectionError(err)) { + return res.status(503).json({ error: "База данных не подключена", connectionError: true }); + } + return res.status(500).json({ error: fallbackMessage }); +} + +function strParam(v: unknown): string | null { + return typeof v === "string" && v.trim() ? v.trim() : null; +} + +// Границы периода: из query или текущий календарный квартал (НДС — квартальный налог) +async function resolvePeriod(dateFrom: unknown, dateTo: unknown): Promise<{ from: string; to: string }> { + const fromParam = strParam(dateFrom); + const toParam = strParam(dateTo); + if (fromParam && toParam) return { from: fromParam, to: toParam }; + const bounds = await cachedQuery("nds:period-bounds", TTL_NDS, () => + query(`SELECT date_trunc('quarter', CURRENT_DATE)::date AS qstart, + (date_trunc('quarter', CURRENT_DATE) + interval '3 months' - interval '1 day')::date AS qend`) + ); + const row = bounds.rows[0] as any; + const fmt = (d: any) => (typeof d === "string" ? d : new Date(d).toISOString().slice(0, 10)); + return { from: fromParam || fmt(row.qstart), to: toParam || fmt(row.qend) }; +} + +export function registerNdsRoutes(app: Express): void { + // Сводка за период: KPI, помесячная динамика с накопительным итогом внутри квартала, + // разрез по ставкам, структура начислений по типам операций, доля авансов + app.get("/api/di2/nds/summary", async (req, res) => { + try { + const { from, to } = await resolvePeriod(req.query.dateFrom, req.query.dateTo); + const params = [from, to]; + + const [totals, monthly, byRate, events, advances] = await Promise.all([ + cachedQuery(stableKey("nds:summary:totals", req.query), TTL_NDS, () => + query( + `SELECT COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0)::float8 AS accrued, + COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0)::float8 AS deducted, + (COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0) + - COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0))::float8 AS "toPay" + FROM nds_monthly WHERE month >= $1::date AND month <= $2::date`, + params + ) + ), + cachedQuery(stableKey("nds:summary:monthly", req.query), TTL_NDS, () => + query( + `SELECT month::text AS period, + COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0)::float8 AS accrued, + COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0)::float8 AS deducted, + (COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0) + - COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0))::float8 AS "toPay", + SUM(COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0) + - COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0)) + OVER (PARTITION BY date_trunc('quarter', month) ORDER BY month)::float8 AS "vatCumulative" + FROM nds_monthly + WHERE month >= $1::date AND month <= $2::date + GROUP BY month ORDER BY month`, + params + ) + ), + cachedQuery(stableKey("nds:summary:rates", req.query), TTL_NDS, () => + query( + `SELECT direction, vat_rate AS rate, + ROUND(SUM(sum_no_vat)::numeric, 2)::float8 AS "totalNoVat", + ROUND(SUM(vat)::numeric, 2)::float8 AS vat + FROM nds_monthly + WHERE month >= $1::date AND month <= $2::date + GROUP BY direction, vat_rate + ORDER BY direction, vat DESC`, + params + ) + ), + cachedQuery(stableKey("nds:summary:events", req.query), TTL_NDS, () => + query( + `SELECT month::text AS period, event, ROUND(SUM(vat)::numeric, 2)::float8 AS vat + FROM nds_monthly + WHERE direction = 'sales' AND month >= $1::date AND month <= $2::date + GROUP BY month, event ORDER BY month, vat DESC`, + params + ) + ), + cachedQuery(stableKey("nds:summary:advances", req.query), TTL_NDS, () => + query( + `SELECT month::text AS period, + ROUND(COALESCE(SUM(vat) FILTER (WHERE event = 'Получен аванс'), 0)::numeric, 2)::float8 AS "vatFromAdvances", + ROUND(SUM(vat)::numeric, 2)::float8 AS "vatTotal", + ROUND(100.0 * COALESCE(SUM(vat) FILTER (WHERE event = 'Получен аванс'), 0) / NULLIF(SUM(vat), 0), 1)::float8 AS "pctAdvances" + FROM nds_monthly + WHERE direction = 'sales' AND month >= $1::date AND month <= $2::date + GROUP BY month ORDER BY month`, + params + ) + ), + ]); + + res.json({ + dateFrom: from, + dateTo: to, + totals: totals.rows[0], + monthly: monthly.rows, + byRate: byRate.rows, + events: events.rows, + advances: advances.rows, + }); + } catch (err) { + handleApiError(res, err); + } + }); + + // История по кварталам (к уплате считается заново каждый квартал, без переноса) + app.get("/api/di2/nds/quarters", async (req, res) => { + try { + const result = await cachedQuery("nds:quarters", TTL_NDS, () => + query( + `SELECT date_trunc('quarter', month)::date::text AS quarter, + COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0)::float8 AS accrued, + COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0)::float8 AS deducted, + (COALESCE(SUM(vat) FILTER (WHERE direction = 'sales'), 0) + - COALESCE(SUM(vat) FILTER (WHERE direction = 'purchases'), 0))::float8 AS "toPay" + FROM nds_monthly + GROUP BY 1 ORDER BY 1` + ) + ); + res.json(result.rows); + } catch (err) { + handleApiError(res, err); + } + }); + + // «Резерв вычетов» — входящий НДС, ещё не принятый к вычету (остаток на счёте 19) + app.get("/api/di2/nds/input-balance", async (req, res) => { + try { + const result = await cachedQuery("nds:input-balance", TTL_NDS, () => + query( + `SELECT snapshot_date::text AS date, vat_balance::float8 AS "vatBalance" + FROM nds_input_balance + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '90 days' + ORDER BY snapshot_date` + ) + ); + res.json(result.rows); + } catch (err) { + handleApiError(res, err); + } + }); + + // Маркер «факт денег vs факт в 1С» по договорам — контроль закрытия квартала. + // Главный сигнал: RED_NO_SHIPMENT с paid > 1 млн (НДС с аванса уже в «к уплате», актов нет). + // Ограничение: сверка по договорам как их заполняют в документах; пустой договор = не заполнен в учёте. + app.get("/api/di2/nds/contract-markers", async (req, res) => { + try { + const marker = strParam(req.query.marker); + const minPaidRaw = strParam(req.query.minPaid); + const minPaid = minPaidRaw ? Number(minPaidRaw) : null; + + const [byMarker, rows] = await Promise.all([ + cachedQuery("nds:contract-markers:by-marker", TTL_NDS, () => + query( + `SELECT marker, COUNT(*)::int AS count, ROUND(SUM(paid)::numeric, 2)::float8 AS paid + FROM nds_contract_marker + GROUP BY marker ORDER BY count DESC` + ) + ), + cachedQuery(stableKey("nds:contract-markers:rows", req.query), TTL_NDS, () => { + const params: unknown[] = []; + const conds: string[] = []; + if (marker) { params.push(marker); conds.push(`marker = $${params.length}`); } + if (minPaid !== null && Number.isFinite(minPaid)) { params.push(minPaid); conds.push(`paid > $${params.length}`); } + const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; + return query( + `SELECT counterparty, contract, + paid::float8 AS paid, shipped::float8 AS shipped, + advance_balance::float8 AS "advanceBalance", diff::float8 AS diff, marker + FROM nds_contract_marker ${where} + ORDER BY paid DESC`, + params + ); + }), + ]); + + res.json({ byMarker: byMarker.rows, rows: rows.rows }); + } catch (err) { + handleApiError(res, err); + } + }); + + // Свежесть витрины: последняя успешная синхронизация; stale = данные не обновлялись >36 часов + app.get("/api/di2/nds/sync-status", async (req, res) => { + try { + const result = await cachedQuery("nds:sync-status", TTL_SYNC, () => + query( + `SELECT started_at AS "startedAt", rows_monthly AS "rowsMonthly", message + FROM nds_sync_log + WHERE status = 'ok' + ORDER BY started_at DESC LIMIT 1` + ) + ); + const row = result.rows[0] as any; + if (!row) { + return res.json({ startedAt: null, rowsMonthly: 0, message: null, stale: true }); + } + const startedAt = typeof row.startedAt === "string" ? row.startedAt : new Date(row.startedAt).toISOString(); + const stale = new Date(startedAt).getTime() < Date.now() - 36 * 60 * 60 * 1000; + res.json({ startedAt, rowsMonthly: Number(row.rowsMonthly) || 0, message: row.message || null, stale }); + } catch (err) { + handleApiError(res, err); + } + }); +} diff --git a/server/finance-di2/routes.ts b/server/finance-di2/routes.ts index 6149345..48305a4 100644 --- a/server/finance-di2/routes.ts +++ b/server/finance-di2/routes.ts @@ -4465,94 +4465,6 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< } }); - app.get("/api/calculations/vat", async (req, res) => { - try { - const { dateFrom, dateTo } = req.query; - const incParams: unknown[] = []; - const expParams: unknown[] = []; - const incConds: string[] = ["1=1"]; - const expConds: string[] = ["1=1"]; - - if (dateFrom) { - incParams.push(dateFrom); incConds.push(`data >= $${incParams.length}`); - expParams.push(dateFrom); expConds.push(`data >= $${expParams.length}`); - } - if (dateTo) { - incParams.push(dateTo); incConds.push(`data <= $${incParams.length}`); - expParams.push(dateTo); expConds.push(`data <= $${expParams.length}`); - } - - const incWhere = `WHERE ${incConds.join(" AND ")}`; - const expWhere = `WHERE ${expConds.join(" AND ")}`; - - const [incByRate, expByRate, incMonthly, expMonthly] = await Promise.all([ - cachedQuery(stableKey("vat-inc-rate", req.query), TTL_SHORT, () => - query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM(summa), 0) as total FROM ${getIncomeEffectiveView()} ${incWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, incParams) - ), - cachedQuery(stableKey("vat-exp-rate", req.query), TTL_SHORT, () => - query(`SELECT COALESCE(stavkands, 'Без НДС') as rate, COUNT(*) as cnt, COALESCE(SUM(summadokumenta), 0) as total FROM ${getExpenseEffectiveView()} ${expWhere} GROUP BY COALESCE(stavkands, 'Без НДС') ORDER BY total DESC`, expParams) - ), - cachedQuery(stableKey("vat-inc-monthly", req.query), TTL_SHORT, () => - query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM(summa), 0) as total FROM ${getIncomeEffectiveView()} ${incWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, incParams) - ), - cachedQuery(stableKey("vat-exp-monthly", req.query), TTL_SHORT, () => - query(`SELECT TO_CHAR(data, 'YYYY-MM') as period, COALESCE(stavkands, 'Без НДС') as rate, COALESCE(SUM(summadokumenta), 0) as total FROM ${getExpenseEffectiveView()} ${expWhere} GROUP BY TO_CHAR(data, 'YYYY-MM'), COALESCE(stavkands, 'Без НДС') ORDER BY period`, expParams) - ), - ]); - - const parseRate = (r: string): number => { - const m = r.match(/(\d+)/); - return m ? Number(m[1]) / 100 : 0; - }; - - const incomeByRate = incByRate.rows.map((r: any) => ({ - rate: r.rate, - count: Number(r.cnt), - total: Number(r.total), - vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), - })); - - const expenseByRate = expByRate.rows.map((r: any) => ({ - rate: r.rate, - count: Number(r.cnt), - total: Number(r.total), - vat: Number(r.total) * parseRate(r.rate) / (1 + parseRate(r.rate)), - })); - - const totalIncomeVat = incomeByRate.reduce((s: number, r: any) => s + r.vat, 0); - const totalExpenseVat = expenseByRate.reduce((s: number, r: any) => s + r.vat, 0); - - const periodsSet = new Set(); - [...incMonthly.rows, ...expMonthly.rows].forEach((r: any) => periodsSet.add(r.period)); - const periods = [...periodsSet].sort(); - - const monthly = periods.map(p => { - let incVat = 0; - let expVat = 0; - incMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { - const rate = parseRate(r.rate); - incVat += Number(r.total) * rate / (1 + rate); - }); - expMonthly.rows.filter((r: any) => r.period === p).forEach((r: any) => { - const rate = parseRate(r.rate); - expVat += Number(r.total) * rate / (1 + rate); - }); - return { period: p, incomeVat: Math.round(incVat * 100) / 100, expenseVat: Math.round(expVat * 100) / 100, toPay: Math.round((incVat - expVat) * 100) / 100 }; - }); - - res.json({ - incomeByRate, - expenseByRate, - totalIncomeVat: Math.round(totalIncomeVat * 100) / 100, - totalExpenseVat: Math.round(totalExpenseVat * 100) / 100, - toPay: Math.round((totalIncomeVat - totalExpenseVat) * 100) / 100, - monthly, - }); - } catch (err) { - handleApiError(res, err); - } - }); - app.post("/api/calculations/profit-tax", async (req, res) => { try { await ensureTreesReady();