НДС в DI2: вкладка «Расчёты → НДС» переведена на декларационную витрину 1С

- Новый 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 из платежей) удалён
This commit is contained in:
2026-09-29 07:49:23 +03:00
parent c0b8910094
commit de9bbfe17a
4 changed files with 499 additions and 228 deletions

View File

@@ -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);
}
});
}