// Эндпоинты витрины НДС (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 или вся история витрины (дефолт). // Отдельно возвращает границы квартала для 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); 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 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: начислено/вычет — за период; «к уплате» — за квартал (currentQuarter), НДС не копится между кварталами. app.get("/api/di2/nds/summary", async (req, res) => { try { const { from, to, quarter } = await resolvePeriod(req.query.dateFrom, req.query.dateTo); const params = [from, to]; const qParams = [quarter.from, quarter.to]; 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`, qParams ) ), 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], currentQuarter: { dateFrom: quarter.from, dateTo: quarter.to, ...quarterTotals.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); } }); // Трекер авансовых УПД поставщиков (spec: nds_advance_vat_tracker — «где сбавить НДС»). // Рабочая очередь = status 'НЕТ АВАНСОВОЙ УПД'; строки 'ПОЛУЧЕНО' не показываем. // potential_vat — оценка сверху по 22/122, реальный вычет = НДС из УПД поставщика. app.get("/api/di2/nds/advance-vat-tracker", async (req, res) => { try { const [kpis, rows] = await Promise.all([ cachedQuery("nds:adv-vat:kpis", TTL_NDS, () => query( `SELECT ROUND(SUM(potential_vat), 2)::float8 AS "vatToSave", COUNT(*)::int AS "suppliersWaiting", ROUND(SUM(advance_out), 2)::float8 AS "advancesTotal", MAX(snapshot_date)::text AS "snapshotDate" FROM nds_advance_vat_tracker WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM nds_advance_vat_tracker) AND status = 'НЕТ АВАНСОВОЙ УПД'` ) ), cachedQuery("nds:adv-vat:rows", TTL_NDS, () => query( `SELECT supplier, contract, advance_out::float8 AS "advanceOut", potential_vat::float8 AS "potentialVat" FROM nds_advance_vat_tracker WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM nds_advance_vat_tracker) AND status = 'НЕТ АВАНСОВОЙ УПД' ORDER BY potential_vat DESC` ) ), ]); res.json({ kpis: kpis.rows[0], rows: rows.rows }); } catch (err) { handleApiError(res, err); } }); // Виджет «Контроль квартала»: главный список RED с висячим авансом, // отдельный список RED с нулевым авансом (проверить зачёт), итоги и полный список (?all=1) app.get("/api/di2/nds/quarter-control", async (req, res) => { try { if (String(req.query.all) === "1") { const result = await cachedQuery("nds:quarter-control:all", TTL_NDS, () => 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 ORDER BY advance_balance DESC, diff DESC` ) ); return res.json({ rows: result.rows }); } const [redHanging, redNoAdvance, totals, byMarker] = await Promise.all([ cachedQuery("nds:quarter-control:red-hanging", TTL_NDS, () => query( `SELECT counterparty, contract, paid::float8 AS paid, shipped::float8 AS shipped, advance_balance::float8 AS "advanceBalance", diff::float8 AS diff FROM nds_contract_marker WHERE marker = 'RED_NO_SHIPMENT' AND advance_balance > 0 ORDER BY paid DESC` ) ), cachedQuery("nds:quarter-control:red-no-advance", TTL_NDS, () => query( `SELECT counterparty, contract, paid::float8 AS paid FROM nds_contract_marker WHERE marker = 'RED_NO_SHIPMENT' AND advance_balance = 0 ORDER BY paid DESC` ) ), cachedQuery("nds:quarter-control:totals", TTL_NDS, () => query( `SELECT COUNT(*)::int AS "totalContracts", ROUND(COALESCE(SUM(advance_balance), 0)::numeric, 2)::float8 AS "totalAdvance" FROM nds_contract_marker` ) ), cachedQuery("nds:quarter-control:by-marker", TTL_NDS, () => query( `SELECT marker, COUNT(*)::int AS count FROM nds_contract_marker GROUP BY marker ORDER BY count DESC` ) ), ]); res.json({ redHanging: redHanging.rows, redNoAdvance: redNoAdvance.rows, totals: totals.rows[0], byMarker: byMarker.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); } }); }