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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user