НДС в DI2: трекер авансовых УПД, доработка графика и виджета «Контроль квартала»

- Новый GET /api/di2/nds/advance-vat-tracker (nds_advance_vat_tracker): KPI «сколько можем
  сбавить НДС» (оценка сверху 22/122) + топ-15 поставщиков без авансовой УПД; карточка
  сразу после «Динамики НДС» с обязательными оговорками из спеки
- Новый GET /api/di2/nds/quarter-control: redHanging / redNoAdvance («проверить зачёт») /
  totals / byMarker / ?all=1 — по правкам виджета из спеки
- График «Динамика НДС» (месяц): два столбца «К уплате за месяц» и «Накоплено за квартал»,
  кварталы выделены фоновыми полосами (ReferenceArea)
- Виджет «Контроль квартала»: легенда с подписями маркеров, итог «N договоров, сумма
  авансов X», кнопка «Показать все» (полный список с маркерами), отдельный список
  «Оплачено, аванс не висячий», полный год в датах договоров, overflow-x для таблиц
This commit is contained in:
2026-09-29 11:35:30 +03:00
parent 5fe0e30ea0
commit 6ef0621d63
2 changed files with 318 additions and 45 deletions

View File

@@ -13,7 +13,7 @@ import { DatabaseError } from "@/components/database-error";
import { Badge } from "@/components/ui/badge";
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Line, ComposedChart, ReferenceArea } from "recharts";
import { formatCurrency } from "@/lib/format";
interface CatNode {
@@ -437,10 +437,17 @@ export default function Calculations() {
refetchInterval: 5 * 60 * 1000,
});
// Маркер «деньги vs отгрузка» по договорам: красные флаги квартала (главный запрос спецификации)
const ndsMarkersQuery = useQuery<any>({
queryKey: ["/api/di2/nds/contract-markers"],
queryFn: () => ndsFetch("/api/di2/nds/contract-markers?marker=RED_NO_SHIPMENT&minPaid=1000000"),
// Трекер авансовых УПД поставщиков («где сбавить НДС» — spec nds_advance_vat_tracker)
const ndsTrackerQuery = useQuery<any>({
queryKey: ["/api/di2/nds/advance-vat-tracker"],
queryFn: () => ndsFetch("/api/di2/nds/advance-vat-tracker"),
enabled: activeTab === "vat",
});
// Виджет «Контроль квартала»: маркер «деньги vs отгрузка» по договорам
const ndsQuarterControlQuery = useQuery<any>({
queryKey: ["/api/di2/nds/quarter-control"],
queryFn: () => ndsFetch("/api/di2/nds/quarter-control"),
enabled: activeTab === "vat",
});
@@ -560,7 +567,8 @@ export default function Calculations() {
quarters={ndsQuartersQuery.data ?? []}
balance={ndsBalanceQuery.data ?? []}
sync={ndsSyncQuery.data ?? null}
markers={ndsMarkersQuery.data ?? null}
tracker={ndsTrackerQuery.data ?? null}
quarterControl={ndsQuarterControlQuery.data ?? null}
/>
)}
{activeTab === "profit" && profitQuery.data && !isLoading && (
@@ -605,19 +613,41 @@ function ChartTooltip({ active, payload, label, formatValue }: any) {
);
}
// Маркеры «деньги vs отгрузка» (словарь спецификации §2.3)
const MARKER_META: Record<string, { label: string; color: string }> = {
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" },
// Маркеры «деньги vs отгрузка» (словарь спецификации §2.3 + ревью: подписи виджета).
// В БД встречаются старые и новые имена — маппим оба.
const MARKER_META: Record<string, { label: string; short: string; color: string }> = {
GREEN_OK: { label: "Оплачено и отгружено сошлось", short: "Сошлось", color: "#16a34a" },
YELLOW_PAID_GT_SHIPPED: { label: "Оплачено больше, чем отгружено (аванс на 62.02)", short: "Аванс 62.02", color: "#f59e0b" },
YELLOW_ADVANCE: { label: "Оплачено больше, чем отгружено (аванс на 62.02)", short: "Аванс 62.02", color: "#f59e0b" },
BLUE_SHIPPED_GT_PAID: { label: "Отгружено больше, чем оплачено (долг покупателя)", short: "Долг покупателя", color: "#2563eb" },
BLUE_DEBT: { label: "Отгружено больше, чем оплачено (долг покупателя)", short: "Долг покупателя", color: "#2563eb" },
RED_NO_SHIPMENT: { label: "Оплачено, отгрузок нет", short: "Нет отгрузки", color: "#e11d48" },
WHITE_ADVANCE_OPEN: { label: "В документах не выбран договор", short: "Нет договора", color: "#94a3b8" },
WHITE_NO_CONTRACT: { label: "В документах не выбран договор", short: "Нет договора", color: "#94a3b8" },
};
function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarters: any[]; balance: any[]; sync: any; markers: any }) {
// «от 07.08.26» → «от 07.08.2026» (полный год в строке договора)
function formatContractDate(s: string): string {
return s.replace(/(\d{2})\.(\d{2})\.(\d{2})(?!\d)/g, "$1.$2.20$3");
}
function VatTab({ data, quarters, balance, sync, tracker, quarterControl }: {
data: any; quarters: any[]; balance: any[]; sync: any; tracker: any; quarterControl: any;
}) {
const [groupBy, setGroupBy] = useState<"month" | "quarter">(
() => (localStorage.getItem("omegaprom_vat_groupby") === "quarter" ? "quarter" : "month")
);
const [showAllContracts, setShowAllContracts] = useState(false);
const allContractsQuery = useQuery<any>({
queryKey: ["/api/di2/nds/quarter-control-all"],
queryFn: async () => {
const r = await fetch("/api/di2/nds/quarter-control?all=1");
if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`);
return r.json();
},
enabled: showAllContracts,
});
function changeGroupBy(v: "month" | "quarter") {
setGroupBy(v);
@@ -628,6 +658,17 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
const chartData = groupBy === "month" ? (data.monthly ?? []) : quarters;
const periodLabel = (v: string) => (groupBy === "month" ? formatPeriodLabel(v, "month") : formatQuarterLabel(v));
// Фоновые полосы по календарным кварталам для помесячного графика
const quarterBands = useMemo(() => {
const byQ = new Map<string, string[]>();
for (const r of data.monthly ?? []) {
const q = `${r.period.slice(0, 4)}-Q${Math.floor((Number(r.period.slice(5, 7)) - 1) / 3) + 1}`;
if (!byQ.has(q)) byQ.set(q, []);
byQ.get(q)!.push(r.period);
}
return [...byQ.entries()].map(([key, periods]) => ({ key, x1: periods[0], x2: periods[periods.length - 1] }));
}, [data.monthly]);
// Поворот (period, event, vat) в столбцы для stacked-диаграммы — чистое отображение, без расчётов
const eventNames = useMemo(() => {
const names = new Set<string>();
@@ -646,8 +687,12 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
const salesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "sales");
const purchasesByRate = (data.byRate ?? []).filter((r: any) => r.direction === "purchases");
const quarter = data.currentQuarter ?? null;
const redRows: any[] = markers?.rows ?? [];
const markerCounts: any[] = markers?.byMarker ?? [];
const redHanging: any[] = quarterControl?.redHanging ?? [];
const redNoAdvance: any[] = quarterControl?.redNoAdvance ?? [];
const contractTotals = quarterControl?.totals ?? null;
const markerCounts: any[] = quarterControl?.byMarker ?? [];
const allContractRows: any[] = allContractsQuery.data?.rows ?? [];
const redHangingAdvanceSum = redHanging.reduce((s: number, r: any) => s + (r.advanceBalance || 0), 0);
return (
<div className="space-y-4">
@@ -733,6 +778,15 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
<ResponsiveContainer width="100%" height={350}>
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
{groupBy === "month" && quarterBands.map((b, i) => (
<ReferenceArea
key={b.key}
x1={b.x1}
x2={b.x2}
fill="hsl(var(--muted))"
fillOpacity={i % 2 === 0 ? 0.15 : 0.05}
/>
))}
<XAxis
dataKey="period"
tickFormatter={periodLabel}
@@ -743,23 +797,75 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={tickFormatter} />
<Tooltip content={(props: any) => <ChartTooltip {...props} label={periodLabel(props.label)} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Bar dataKey="accrued" name="Начислено" fill={COLORS.income} radius={[3, 3, 0, 0]} />
<Bar dataKey="deducted" name="Вычет" fill={COLORS.expense} radius={[3, 3, 0, 0]} />
<Line dataKey="toPay" name="К уплате" stroke={COLORS.toPay} strokeWidth={2} dot={{ r: 3 }} />
{groupBy === "month" && (
<Line dataKey="vatCumulative" name="Накоп. за квартал" stroke={COLORS.cumulative} strokeWidth={2} dot={{ r: 3 }} strokeDasharray="5 5" />
{groupBy === "month" ? (
<>
<Bar dataKey="toPay" name="К уплате за месяц" fill={COLORS.toPay} radius={[3, 3, 0, 0]} />
<Bar dataKey="vatCumulative" name="Накоплено за квартал" fill={COLORS.cumulative} radius={[3, 3, 0, 0]} />
</>
) : (
<>
<Bar dataKey="accrued" name="Начислено" fill={COLORS.income} radius={[3, 3, 0, 0]} />
<Bar dataKey="deducted" name="Вычет" fill={COLORS.expense} radius={[3, 3, 0, 0]} />
<Line dataKey="toPay" name="К уплате" stroke={COLORS.toPay} strokeWidth={2} dot={{ r: 3 }} />
</>
)}
</ComposedChart>
</ResponsiveContainer>
{groupBy === "month" && (
<p className="text-xs text-muted-foreground mt-2">
Пунктирная линия — накопительный итог внутри календарного квартала. НДС не переносится между кварталами: «к уплате» считается заново каждый квартал
Фоновые полосы — календарные кварталы. Синий столбец — НДС к уплате за месяц, бирюзовый — накоплено с начала квартала (на границе квартала сбрасывается; НДС между кварталами не переносится)
</p>
)}
</CardContent>
</Card>
)}
{tracker?.kpis && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Сбавить НДС: запросить авансовые УПД у поставщиков
</CardTitle>
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className="text-muted-foreground">Потенциал вычета: <span className="font-semibold text-foreground tabular-nums">{formatCurrency(tracker.kpis.vatToSave)}</span></span>
<span className="text-muted-foreground">Ждут УПД: <span className="font-semibold text-foreground">{tracker.kpis.suppliersWaiting}</span></span>
<span className="text-muted-foreground">Авансы: <span className="font-semibold text-foreground tabular-nums">{formatCurrency(tracker.kpis.advancesTotal)}</span></span>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{(tracker.rows ?? []).length > 0 ? (
<div className="overflow-x-auto">
<div className="min-w-[560px] space-y-1">
<div className="grid grid-cols-[1.2fr_1fr_auto_auto] gap-3 text-xs text-muted-foreground pb-1 border-b border-border">
<span>Поставщик</span>
<span>Договор/счёт</span>
<span className="text-right">Аванс выданный</span>
<span className="text-right pr-2">Можем сбавить</span>
</div>
{tracker.rows.map((r: any, i: number) => (
<div key={i} className="grid grid-cols-[1.2fr_1fr_auto_auto] gap-3 text-sm py-1 items-baseline">
<span className="text-foreground truncate" title={r.supplier}>{r.supplier}</span>
<span className="text-muted-foreground truncate" title={r.contract}>{formatContractDate(r.contract || "—")}</span>
<span className="tabular-nums text-right text-muted-foreground">{formatCurrency(r.advanceOut)}</span>
<span className="tabular-nums text-right font-medium text-foreground pr-2">{formatCurrency(r.potentialVat)}</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Все авансовые УПД получены — вычеты с выданных авансов исчерпаны</p>
)}
<p className="text-xs text-muted-foreground mt-3">
Оценка сверху по 22/122: реальный вычет = НДС из УПД поставщика (ИП на ставке 5% дадут меньше, поставщики на УСН УПД не выставят).
Госпошлина, лизинг, банки — вычета по авансу там не бывает. При поступлении товара вычет с аванса восстанавливается (п. 8 ст. 171 НК) — двойного вычета не будет, экономия кассовая.
{tracker.kpis.snapshotDate ? ` Данные на ${tracker.kpis.snapshotDate}.` : ""}
</p>
</CardContent>
</Card>
)}
{eventsChart.length > 0 && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
@@ -785,20 +891,20 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
</Card>
)}
{markers && (
{quarterControl && (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Контроль квартала: деньги есть — отгрузки нет
</CardTitle>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
{markerCounts.map((m: any) => {
const meta = MARKER_META[m.marker] ?? { label: m.marker, color: "#94a3b8" };
const meta = MARKER_META[m.marker] ?? { label: m.marker, short: m.marker, color: "#94a3b8" };
return (
<span key={m.marker} className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" title={meta.label}>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: meta.color }} />
{m.count}
{meta.short} · {m.count}
</span>
);
})}
@@ -806,30 +912,100 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{redRows.length > 0 ? (
<div className="space-y-1">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 text-xs text-muted-foreground pb-1 border-b border-border">
<span>Контрагент</span>
<span>Договор</span>
<span className="text-right">Оплачено с начала года</span>
<span className="text-right">Аванс на 62.02</span>
</div>
{redRows.map((r: any, i: number) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 text-sm py-1 items-baseline">
<span className="text-foreground">{r.counterparty}</span>
<span className="text-muted-foreground truncate" title={r.contract || "Договор в документах не заполнен"}>
{r.contract || "— договор не заполнен —"}
</span>
<span className="tabular-nums text-right font-medium text-foreground">{formatCurrency(r.paid)}</span>
<span className="tabular-nums text-right text-muted-foreground">{formatCurrency(r.advanceBalance)}</span>
{showAllContracts ? (
<div className="space-y-2">
{allContractsQuery.isLoading ? (
<Skeleton className="h-[200px] rounded-xl" />
) : (
<div className="overflow-x-auto">
<div className="min-w-[640px] space-y-1">
<div className="grid grid-cols-[auto_1.3fr_1fr_auto_auto] gap-3 text-xs text-muted-foreground pb-1 border-b border-border items-center">
<span className="w-2" />
<span>Контрагент</span>
<span>Договор</span>
<span className="text-right">Оплачено</span>
<span className="text-right pr-2">Аванс 62.02</span>
</div>
{allContractRows.map((r: any, i: number) => {
const meta = MARKER_META[r.marker] ?? { label: r.marker, short: r.marker, color: "#94a3b8" };
return (
<div key={i} className="grid grid-cols-[auto_1.3fr_1fr_auto_auto] gap-3 text-sm py-1 items-baseline">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: meta.color }} title={meta.label} />
<span className="text-foreground truncate" title={r.counterparty}>{r.counterparty}</span>
<span className="text-muted-foreground truncate" title={r.contract || "Договор в документах не заполнен"}>
{r.contract ? formatContractDate(r.contract) : "— договор не заполнен —"}
</span>
<span className="tabular-nums text-right text-foreground">{formatCurrency(r.paid)}</span>
<span className="tabular-nums text-right text-muted-foreground pr-2">{formatCurrency(r.advanceBalance)}</span>
</div>
);
})}
</div>
</div>
))}
)}
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setShowAllContracts(false)}>
Свернуть
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">Крупных необеспеченных авансов (более 1 млн ₽ без отгрузки) нет</p>
<div className="space-y-3">
{redHanging.length > 0 ? (
<div className="overflow-x-auto">
<div className="min-w-[640px] space-y-1">
<div className="grid grid-cols-[1.3fr_1fr_auto_auto] gap-3 text-xs text-muted-foreground pb-1 border-b border-border">
<span>Контрагент</span>
<span>Договор</span>
<span className="text-right">Оплачено с начала года</span>
<span className="text-right pr-2">Аванс на 62.02</span>
</div>
{redHanging.map((r: any, i: number) => (
<div key={i} className="grid grid-cols-[1.3fr_1fr_auto_auto] gap-3 text-sm py-1 items-baseline">
<span className="text-foreground truncate" title={r.counterparty}>{r.counterparty}</span>
<span className="text-muted-foreground truncate" title={r.contract || "Договор в документах не заполнен"}>
{r.contract ? formatContractDate(r.contract) : "— договор не заполнен —"}
</span>
<span className="tabular-nums text-right font-medium text-foreground">{formatCurrency(r.paid)}</span>
<span className="tabular-nums text-right text-muted-foreground pr-2">{formatCurrency(r.advanceBalance)}</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Договоров с висячим авансом и без отгрузки нет</p>
)}
{redNoAdvance.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Оплачено, аванс не висячий — проверить зачёт у бухгалтерии ({redNoAdvance.length}):
</p>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
{redNoAdvance.map((r: any, i: number) => (
<span key={i} className="tabular-nums">{r.counterparty} — {formatCurrency(r.paid)}</span>
))}
</div>
</div>
)}
<div className="flex items-center justify-between flex-wrap gap-2 pt-1">
<p className="text-xs text-muted-foreground">
Итого по списку: {redHanging.length} договоров, сумма авансов {formatCurrency(redHangingAdvanceSum)}
{contractTotals ? ` · всего со сверкой ${contractTotals.totalContracts} договоров` : ""}
</p>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setShowAllContracts(true)}
disabled={allContractsQuery.isLoading && showAllContracts}
>
Показать все
</Button>
</div>
</div>
)}
<p className="text-xs text-muted-foreground mt-3">
Договоры со сверкой: {markerCounts.reduce((s: number, m: any) => s + m.count, 0)}. НДС с этих авансов уже попал в «к уплате», а выручка и зачёт — в будущем.
НДС с этих авансов уже попал в «к уплате», а выручка и зачёт — в будущем.
Пустой договор — в платежах/реализациях договор не выбран (самостоятельная проблема учёта).
</p>
</CardContent>