НДС в DI2: трекер авансовых УПД, доработка графика и виджета «Контроль квартала»

- Новый GET /api/di2/nds/advance-vat-tracker (nds_advance_vat_tracker): KPI «сколько можем
  сбавить НДС» (оценка сверху 22/122) + топ-15 поставщиков без авансовой УПД; карточка
  сразу после «Динамики НДС» с обязательными оговорками из спеки
- Новый GET /api/di2/nds/quarter-control: redHanging / redNoAdvance («проверить зачёт») /
  totals / byMarker / ?all=1 — по правкам виджета из спеки
- График «Динамика НДС» (месяц): два столбца «К уплате за месяц» и «Накоплено за квартал»,
  кварталы выделены фоновыми полосами (ReferenceArea)
- Виджет «Контроль квартала»: легенда с подписями маркеров, итог «N договоров, сумма
  авансов X», кнопка «Показать все» (полный список с маркерами), отдельный список
  «Оплачено, аванс не висячий», полный год в датах договоров, overflow-x для таблиц
This commit is contained in:
2026-09-29 11:35:30 +03:00
parent 5fe0e30ea0
commit 6ef0621d63
2 changed files with 318 additions and 45 deletions

View File

@@ -230,6 +230,103 @@ export function registerNdsRoutes(app: Express): void {
}
});
// Трекер авансовых УПД поставщиков (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
LIMIT 15`
)
),
]);
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 {