НДС в DI2: дефолтный период — вся история витрины, KPI «К уплате» — за квартал

- summary: без фильтров дат период = весь доступный диапазон nds_monthly (с 2025-01)
- «К уплате» теперь всегда квартальный: квартал выбранного периода, если он внутри одного
  квартала, иначе квартал последнего месяца в данных (НДС не копится между кварталами)
- карточки KPI: «Начислено»/«Вычет» за период с подписью, «К уплате» с меткой квартала
This commit is contained in:
2026-09-29 09:15:43 +03:00
parent de9bbfe17a
commit b9ef0b2152
2 changed files with 55 additions and 16 deletions

View File

@@ -645,7 +645,8 @@ 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 totals = data.totals ?? { accrued: 0, deducted: 0, toPay: 0 };
const totals = data.totals ?? { accrued: 0, deducted: 0 };
const quarter = data.currentQuarter ?? null;
const redRows: any[] = markers?.rows ?? [];
const markerCounts: any[] = markers?.byMarker ?? [];
@@ -661,6 +662,7 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
<span className="text-sm text-muted-foreground">Начислено</span>
</div>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-income-vat">{formatCurrency(totals.accrued)}</p>
<p className="text-xs text-muted-foreground mt-0.5">за период{data.dateFrom && data.dateTo ? ` ${data.dateFrom} — ${data.dateTo}` : ""}</p>
</CardContent>
</Card>
<Card>
@@ -672,6 +674,7 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
<span className="text-sm text-muted-foreground">Вычет</span>
</div>
<p className="text-xl font-bold text-foreground tabular-nums" data-testid="text-expense-vat">{formatCurrency(totals.deducted)}</p>
<p className="text-xs text-muted-foreground mt-0.5">за период</p>
</CardContent>
</Card>
<Card>
@@ -682,8 +685,11 @@ function VatTab({ data, quarters, balance, sync, markers }: { data: any; quarter
</div>
<span className="text-sm text-muted-foreground">К уплате</span>
</div>
<p className={`text-xl font-bold tabular-nums ${totals.toPay >= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay">
{formatCurrency(totals.toPay)}
<p className={`text-xl font-bold tabular-nums ${(quarter?.toPay ?? 0) >= 0 ? "text-destructive" : "text-success"}`} data-testid="text-vat-to-pay">
{formatCurrency(quarter?.toPay ?? 0)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{quarter ? `квартал ${formatQuarterLabel(quarter.dateFrom)}` : ""}
</p>
</CardContent>
</Card>

View File

@@ -22,37 +22,69 @@ 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 }> {
// Границы периода: из query или вся история витрины (дефолт).
// Отдельно возвращает границы квартала для KPI «К уплате»: квартал явного периода,
// если он внутри одного квартала, иначе квартал последнего месяца в данных.
async function resolvePeriod(dateFrom: unknown, dateTo: unknown): Promise<{ from: string; to: string; quarter: { 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 fmt = (d: unknown): string => (typeof d === "string" ? d : new Date(d as any).toISOString().slice(0, 10));
const bounds = await cachedQuery("nds:data-bounds", TTL_NDS, () =>
query(
`SELECT MIN(month)::date AS dfrom,
(date_trunc('quarter', MAX(month)) + interval '3 months' - interval '1 day')::date AS dto,
date_trunc('quarter', MAX(month))::date AS qfrom,
(date_trunc('quarter', MAX(month)) + interval '3 months' - interval '1 day')::date AS qto
FROM nds_monthly`
)
);
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) };
const quarter = { from: fmt(row.qfrom), to: fmt(row.qto) };
if (fromParam && toParam) {
const q = await cachedQuery(stableKey("nds:explicit-quarter", { fromParam, toParam }), TTL_NDS, () =>
query(
`SELECT date_trunc('quarter', $1::date)::date AS qfrom,
(date_trunc('quarter', $2::date) + interval '3 months' - interval '1 day')::date AS qto,
date_trunc('quarter', $1::date) = date_trunc('quarter', $2::date) AS same`,
[fromParam, toParam]
)
);
const qr = q.rows[0] as any;
if (qr.same) {
quarter.from = fmt(qr.qfrom);
quarter.to = fmt(qr.qto);
}
}
return { from: fromParam || fmt(row.dfrom), to: toParam || fmt(row.dto), quarter };
}
export function registerNdsRoutes(app: Express): void {
// Сводка за период: KPI, помесячная динамика с накопительным итогом внутри квартала,
// разрез по ставкам, структура начислений по типам операций, доля авансов
// Сводка за период: помесячная динамика с накопительным итогом внутри квартала,
// разрез по ставкам, структура начислений по типам операций, доля авансов.
// KPI: начислено/вычет — за период; «к уплате» — за квартал (currentQuarter), НДС не копится между кварталами.
app.get("/api/di2/nds/summary", async (req, res) => {
try {
const { from, to } = await resolvePeriod(req.query.dateFrom, req.query.dateTo);
const { from, to, quarter } = await resolvePeriod(req.query.dateFrom, req.query.dateTo);
const params = [from, to];
const qParams = [quarter.from, quarter.to];
const [totals, monthly, byRate, events, advances] = await Promise.all([
const [totals, quarterTotals, 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
FROM nds_monthly WHERE month >= $1::date AND month <= $2::date`,
params
)
),
cachedQuery(`nds:summary:quarter:${quarter.from}:${quarter.to}`, 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
qParams
)
),
cachedQuery(stableKey("nds:summary:monthly", req.query), TTL_NDS, () =>
@@ -110,6 +142,7 @@ export function registerNdsRoutes(app: Express): void {
dateFrom: from,
dateTo: to,
totals: totals.rows[0],
currentQuarter: { dateFrom: quarter.from, dateTo: quarter.to, ...quarterTotals.rows[0] },
monthly: monthly.rows,
byRate: byRate.rows,
events: events.rows,