- Динамика НДС (месяц): поверх столбцов «к уплате за месяц»/«накоплено за квартал» добавлены линии «Начислено» и «Вычет» - Трекер авансовых УПД: без лимита 15 (все ждущие), настоящая table с фиксированными колонками (table-fixed, truncate+title), видимы первые ~5 строк, дальше скролл внутри блока, шапка прилипает (sticky) - Контроль квартала: главная таблица и «Показать все» — то же оформление table + скролл
352 lines
16 KiB
TypeScript
352 lines
16 KiB
TypeScript
// Эндпоинты витрины НДС (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);
|
||
}
|
||
});
|
||
}
|