feat(admin): expand exchange admin and unify admin UX
Some checks failed
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 2m39s
CI/CD Pipeline / Run Tests (pull_request) Successful in 3m0s
CI/CD Pipeline / Run API Inventory E2E Tests (pull_request) Successful in 35s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-24 13:58:24 +01:00
parent 559b9bc5ef
commit c98ba76081
33 changed files with 2915 additions and 209 deletions

View File

@@ -4,7 +4,17 @@ from __future__ import annotations
from typing import Any
from apps.parsers.models import ParserLoadLog, ProcurementRecord, Proxy
from apps.parsers.models import (
FinancialReport,
FinancialReportLine,
IndustrialCertificateRecord,
IndustrialProductRecord,
InspectionRecord,
ManufacturerRecord,
ParserLoadLog,
ProcurementRecord,
Proxy,
)
from apps.parsers.source_cards import SourceCardService
from apps.registers.models import (
Organization,
@@ -161,7 +171,7 @@ def build_admin_dashboard() -> dict[str, Any]:
{
"label": "Записи источников",
"value": _format_int(total_records),
"caption": "Суммарный объём данных по внешним источникам",
"caption": "Суммарный объём по последним успешным срезам источников",
"tone": "amber",
},
{
@@ -204,20 +214,26 @@ def build_admin_dashboard() -> dict[str, Any]:
),
_build_quick_action(
label="ФНС Excel",
description="Загрузить один или несколько файлов отчётности",
description="Загрузить один или несколько файлов бухгалтерской отчётности",
url_name="admin:parsers_financialreport_upload_excel",
),
_build_quick_action(
label="Логи источников",
description="Проверить последние загрузки и ошибки парсеров",
label="История обновлений",
description="Проверить последние загрузки и ошибки по источникам",
url_name="admin:parsers_parserloadlog_changelist",
),
)
if item is not None
],
"source_cards_note": (
"Карточки и диаграмма строятся по последнему успешному срезу каждого "
"источника. Если успешной загрузки ещё не было, показываем текущее "
"содержимое таблиц."
),
"source_cards": source_cards,
"source_mix": source_mix,
"registry_rows": _build_registry_rows(),
"region_rows_note": _get_region_rows_note(),
"region_rows": _build_region_rows(),
"activity_feed": _build_activity_feed(),
}
@@ -225,31 +241,42 @@ def build_admin_dashboard() -> dict[str, Any]:
def _build_source_cards() -> list[dict[str, Any]]:
cards = sorted(SourceCardService.list_cards(), key=lambda item: item["order"])
total_records = sum(card["records_count"] for card in cards)
max_records = max((card["records_count"] for card in cards), default=0)
max_organizations = max((card["organizations_count"] for card in cards), default=0)
snapshot_totals = [_get_card_snapshot_totals(card) for card in cards]
total_records = sum(item["records_count"] for item in snapshot_totals)
max_records = max((item["records_count"] for item in snapshot_totals), default=0)
max_organizations = max(
(item["organizations_count"] for item in snapshot_totals),
default=0,
)
enriched_cards = []
for index, card in enumerate(cards):
snapshot = snapshot_totals[index]
color = SOURCE_COLORS[index % len(SOURCE_COLORS)]
enriched_cards.append(
{
**card,
"color": color,
"status_tone": STATUS_TONES.get(card["status"], "muted"),
"records_count_label": _format_int(card["records_count"]),
"organizations_count_label": _format_int(card["organizations_count"]),
"records_count": snapshot["records_count"],
"organizations_count": snapshot["organizations_count"],
"records_count_label": _format_int(snapshot["records_count"]),
"organizations_count_label": _format_int(
snapshot["organizations_count"]
),
"metrics_scope_label": snapshot["scope_label"],
"records_share": round(
(card["records_count"] / total_records) * 100, 1
(snapshot["records_count"] / total_records) * 100,
1,
)
if total_records
else 0,
"records_bar_width": _relative_width(
value=card["records_count"],
value=snapshot["records_count"],
max_value=max_records,
),
"organizations_bar_width": _relative_width(
value=card["organizations_count"],
value=snapshot["organizations_count"],
max_value=max_organizations,
),
}
@@ -258,6 +285,159 @@ def _build_source_cards() -> list[dict[str, Any]]:
return enriched_cards
def _get_card_snapshot_totals(card: dict[str, Any]) -> dict[str, Any]:
source_items = card.get("source_items", [])
snapshot_items = [_get_source_item_snapshot(item) for item in source_items]
batch_based = any(item["batch_id"] is not None for item in snapshot_items)
records_count = sum(item["records_count"] for item in snapshot_items)
if card["slug"] == "manufacturers-and-products":
organization_sets = [
_get_source_batch_organizations(item["code"], item["batch_id"])
if item["batch_id"] is not None
else _get_source_all_organizations(item["code"])
for item in snapshot_items
]
organizations_count = (
len(set().union(*organization_sets)) if organization_sets else 0
)
else:
organizations_count = sum(
item["organizations_count"] for item in snapshot_items
)
return {
"records_count": records_count,
"organizations_count": organizations_count,
"scope_label": (
"Последний успешный срез" if batch_based else "Текущее состояние таблиц"
),
}
def _get_source_item_snapshot(item: dict[str, Any]) -> dict[str, Any]:
latest_success_load = item.get("latest_success_load") or {}
batch_id = latest_success_load.get("batch_id")
if batch_id is None:
return {
"code": item["code"],
"batch_id": None,
"records_count": item["records_count"],
"organizations_count": item["organizations_count"],
}
return {
"code": item["code"],
"batch_id": batch_id,
"records_count": _get_source_batch_records_count(item["code"], batch_id),
"organizations_count": len(
_get_source_batch_organizations(item["code"], batch_id)
),
}
def _get_source_batch_records_count(item_code: str, batch_id: int) -> int:
if item_code == "fns_reports":
return FinancialReportLine.objects.filter(report__load_batch=batch_id).count()
if item_code == "industrial":
return IndustrialCertificateRecord.objects.filter(load_batch=batch_id).count()
if item_code == "manufactures":
return ManufacturerRecord.objects.filter(load_batch=batch_id).count()
if item_code == "industrial_products":
return IndustrialProductRecord.objects.filter(load_batch=batch_id).count()
if item_code == "inspections":
return InspectionRecord.objects.filter(load_batch=batch_id).count()
if item_code == "procurements":
return ProcurementRecord.objects.filter(load_batch=batch_id).count()
return 0
def _get_source_batch_organizations(item_code: str, batch_id: int) -> set[str]:
if item_code == "fns_reports":
return set(
FinancialReport.objects.filter(load_batch=batch_id)
.exclude(ogrn="")
.values_list("ogrn", flat=True)
.distinct()
)
if item_code == "industrial":
return set(
IndustrialCertificateRecord.objects.filter(load_batch=batch_id)
.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "manufactures":
return set(
ManufacturerRecord.objects.filter(load_batch=batch_id)
.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "industrial_products":
return set(
IndustrialProductRecord.objects.filter(load_batch=batch_id)
.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "inspections":
return set(
InspectionRecord.objects.filter(load_batch=batch_id)
.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "procurements":
return set(
ProcurementRecord.objects.filter(load_batch=batch_id)
.exclude(customer_inn="")
.values_list("customer_inn", flat=True)
.distinct()
)
return set()
def _get_source_all_organizations(item_code: str) -> set[str]:
if item_code == "fns_reports":
return set(
FinancialReport.objects.exclude(ogrn="")
.values_list("ogrn", flat=True)
.distinct()
)
if item_code == "industrial":
return set(
IndustrialCertificateRecord.objects.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "manufactures":
return set(
ManufacturerRecord.objects.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "industrial_products":
return set(
IndustrialProductRecord.objects.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "inspections":
return set(
InspectionRecord.objects.exclude(inn="")
.values_list("inn", flat=True)
.distinct()
)
if item_code == "procurements":
return set(
ProcurementRecord.objects.exclude(customer_inn="")
.values_list("customer_inn", flat=True)
.distinct()
)
return set()
def _build_source_mix(source_cards: list[dict[str, Any]]) -> dict[str, Any]:
non_empty_cards = [card for card in source_cards if card["records_count"] > 0]
total_records = sum(card["records_count"] for card in non_empty_cards)
@@ -278,13 +458,13 @@ def _build_source_mix(source_cards: list[dict[str, Any]]) -> dict[str, Any]:
cursor = 0.0
gradient_parts: list[str] = []
segments = []
for card in sorted(non_empty_cards, key=lambda item: item["records_count"], reverse=True):
for card in sorted(
non_empty_cards, key=lambda item: item["records_count"], reverse=True
):
share = (card["records_count"] / total_records) * 100
start = cursor
end = start + share
gradient_parts.append(
f"{card['color']} {start:.2f}% {end:.2f}%"
)
gradient_parts.append(f"{card['color']} {start:.2f}% {end:.2f}%")
cursor = end
segments.append(
{
@@ -313,10 +493,11 @@ def _build_registry_rows() -> list[dict[str, Any]]:
uploads_count=Count("uploads", distinct=True),
last_upload_at=Max("uploads__created_at"),
last_actual_date=Max("uploads__actual_date"),
)
.order_by("-active_organizations", "name")[:6]
).order_by("-active_organizations", "name")[:6]
)
max_active = max(
(registry.active_organizations for registry in registries), default=0
)
max_active = max((registry.active_organizations for registry in registries), default=0)
return [
{
@@ -337,9 +518,13 @@ def _build_registry_rows() -> list[dict[str, Any]]:
def _build_region_rows() -> list[dict[str, Any]]:
queryset = ProcurementRecord.objects.exclude(region_code="")
latest_batch_id = _get_latest_success_batch_id(ParserLoadLog.Source.PROCUREMENTS)
if latest_batch_id is not None:
queryset = queryset.filter(load_batch=latest_batch_id)
regions = list(
ProcurementRecord.objects.exclude(region_code="")
.values("region_code")
queryset.values("region_code")
.annotate(
records_count=Count("id"),
organizations_count=Count("customer_inn", distinct=True),
@@ -367,6 +552,12 @@ def _build_region_rows() -> list[dict[str, Any]]:
]
def _get_region_rows_note() -> str:
if _get_latest_success_batch_id(ParserLoadLog.Source.PROCUREMENTS) is not None:
return "Показываем только последний успешный срез ЕИС закупок."
return "Успешной загрузки закупок ещё не было, поэтому используем текущее содержимое таблицы."
def _build_activity_feed() -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
@@ -374,18 +565,17 @@ def _build_activity_feed() -> list[dict[str, Any]]:
events.append(
{
"timestamp": load.created_at,
"title": (
SourceCardService.get_card_title_by_parser_source(load.source)
or load.get_source_display()
),
"title": load.get_source_display(),
"kind": "Источник",
"meta": f"Пакет #{load.batch_id} · {_format_int(load.records_count)} записей",
"note": load.error_message or load.status,
"note": load.error_message or load.get_status_display(),
"tone": STATUS_TONES.get(load.status, "muted"),
}
)
for upload in RegisterUpload.objects.select_related("registry").order_by("-created_at")[:5]:
for upload in RegisterUpload.objects.select_related("registry").order_by(
"-created_at"
)[:5]:
events.append(
{
"timestamp": upload.created_at,
@@ -432,6 +622,21 @@ def _format_int(value: int) -> str:
return f"{value:,}".replace(",", " ")
def _get_latest_success_batch_id(source: str) -> int | None:
latest_load = (
ParserLoadLog.objects.filter(
source=source,
status__in={
ParserLoadLog.Status.SUCCESS,
ParserLoadLog.Status.SKIPPED,
},
)
.order_by("-updated_at", "-created_at")
.first()
)
return latest_load.batch_id if latest_load else None
def _safe_reverse(url_name: str) -> str | None:
try:
return reverse(url_name)