НДС в 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:
222
server/finance-di2/nds.routes.ts
Normal file
222
server/finance-di2/nds.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user