Files
iistwin/server/finance-di2/nds.routes.ts
Ильяс Султанов c56ec26b49 НДС в DI2: линии начислено/вычет на графике, таблицы с прокруткой
- Динамика НДС (месяц): поверх столбцов «к уплате за месяц»/«накоплено за квартал»
  добавлены линии «Начислено» и «Вычет»
- Трекер авансовых УПД: без лимита 15 (все ждущие), настоящая table с фиксированными
  колонками (table-fixed, truncate+title), видимы первые ~5 строк, дальше скролл
  внутри блока, шапка прилипает (sticky)
- Контроль квартала: главная таблица и «Показать все» — то же оформление table + скролл
2026-09-29 12:02:58 +03:00

352 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Эндпоинты витрины НДС (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);
}
});
}