feat(1c): add priority ranking and weekly trend page

This commit is contained in:
igor04091968
2026-05-22 15:50:22 +03:00
parent ff93ddd3cd
commit 70d12dca1d
4 changed files with 413 additions and 3 deletions
+295 -1
View File
@@ -88,6 +88,18 @@ def load_brief_history_records(limit: int = 20) -> list[dict[str, Any]]:
return items
def load_brief_history_payloads(limit: int = 200) -> list[dict[str, Any]]:
history_dir = manager_brief_state_dir() / "history"
if not history_dir.exists():
return []
items: list[dict[str, Any]] = []
for path in sorted(history_dir.glob("*.json"), reverse=True)[:limit]:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["_path"] = path.name
items.append(payload)
return items
def load_brief_history_record(name: str) -> dict[str, Any]:
safe_name = Path(name).name
if not safe_name.endswith(".json"):
@@ -105,6 +117,110 @@ def extract_delta(payload: dict[str, Any]) -> dict[str, Any]:
return {"available": False, "reason": "delta not present in artifact"}
def priority_score_for_change(item: dict[str, Any]) -> float:
if item.get("priority_score") is not None:
try:
return float(item.get("priority_score"))
except (TypeError, ValueError):
return 0.0
score = 0.0
score += max(float(item.get("significance") or 0), 0)
score += max(float(item.get("score_delta") or 0), 0) * 0.8
score += max(float(item.get("open_cases_delta") or 0), 0) * 8
score += max(float(item.get("detections_delta") or 0), 0) * 5
score += max(float(item.get("active_locks_delta") or 0), 0) * 10
return round(score, 2)
def build_weekly_trend_report(payloads: list[dict[str, Any]], days: int = 7) -> dict[str, Any]:
now = datetime.now(UTC)
period_start = now.date().toordinal() - max(days - 1, 0)
filtered: list[dict[str, Any]] = []
for payload in payloads:
generated_at_raw = payload.get("generated_at")
if not generated_at_raw:
continue
try:
generated_at = datetime.fromisoformat(str(generated_at_raw))
except ValueError:
continue
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
if generated_at.date().toordinal() < period_start:
continue
payload["_generated_dt"] = generated_at
filtered.append(payload)
by_day: dict[str, dict[str, Any]] = {}
for payload in filtered:
key = payload["_generated_dt"].date().isoformat()
previous = by_day.get(key)
if previous is None or payload["_generated_dt"] > previous["_generated_dt"]:
by_day[key] = payload
daily_rows: list[dict[str, Any]] = []
previous_summary: dict[str, Any] | None = None
for key in sorted(by_day.keys()):
payload = by_day[key]
summary = payload.get("context", {}).get("portfolio_summary", {})
row = {
"date": key,
"generated_at": payload.get("generated_at"),
"companies_total": int(summary.get("companies_total", 0) or 0),
"critical_total": int(summary.get("critical_total", 0) or 0),
"high_total": int(summary.get("high_total", 0) or 0),
"busy_total": int(summary.get("busy_total", 0) or 0),
"open_cases_total": int(summary.get("open_cases_total", 0) or 0),
"detections_total": int(summary.get("detections_total", 0) or 0),
"activity_30d_total": float(summary.get("activity_30d_total", 0) or 0),
"activity_forecast_30d_total": float(summary.get("activity_forecast_30d_total", 0) or 0),
}
if previous_summary:
row["critical_delta_vs_prev_day"] = row["critical_total"] - int(previous_summary.get("critical_total", 0) or 0)
row["busy_delta_vs_prev_day"] = row["busy_total"] - int(previous_summary.get("busy_total", 0) or 0)
row["open_cases_delta_vs_prev_day"] = row["open_cases_total"] - int(previous_summary.get("open_cases_total", 0) or 0)
row["detections_delta_vs_prev_day"] = row["detections_total"] - int(previous_summary.get("detections_total", 0) or 0)
else:
row["critical_delta_vs_prev_day"] = 0
row["busy_delta_vs_prev_day"] = 0
row["open_cases_delta_vs_prev_day"] = 0
row["detections_delta_vs_prev_day"] = 0
daily_rows.append(row)
previous_summary = summary
weekly_changes: dict[tuple[str, str], dict[str, Any]] = {}
for payload in filtered:
delta = extract_delta(payload)
for item in delta.get("top_changes", []):
key = (str(item.get("infobase") or ""), str(item.get("company") or ""))
candidate = dict(item)
candidate["generated_at"] = payload.get("generated_at")
candidate["priority_score"] = priority_score_for_change(candidate)
existing = weekly_changes.get(key)
if existing is None or float(candidate["priority_score"]) > float(existing.get("priority_score") or 0):
weekly_changes[key] = candidate
weekly_top_changes = sorted(
weekly_changes.values(),
key=lambda item: (
float(item.get("priority_score") or 0),
float(item.get("significance") or 0),
int(item.get("open_cases_delta") or 0),
),
reverse=True,
)[:20]
latest = daily_rows[-1] if daily_rows else None
return {
"days": days,
"period_start": datetime.fromordinal(period_start).date().isoformat(),
"period_end": now.date().isoformat(),
"daily": daily_rows,
"latest": latest,
"top_weekly_changes": weekly_top_changes,
}
def grafana_company_dashboard_url() -> str:
return os.getenv(
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
@@ -159,6 +275,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
history_url = "/api/1/analytics-1c/manager/brief/history"
history_html_url = "/manager/briefs"
delta_html_url = "/manager/changes"
weekly_html_url = "/manager/trends/weekly"
problematic_1d_url = "/manager/problematic?days=1"
problematic_7d_url = "/manager/problematic?days=7"
json_url = "/api/1/analytics-1c/manager/brief/latest"
@@ -457,6 +574,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
<a href="{html.escape(md_url)}">Markdown</a>
<a href="{html.escape(history_html_url)}">История brief</a>
<a href="{html.escape(delta_html_url)}">Что изменилось</a>
<a href="{html.escape(weekly_html_url)}">Неделя</a>
<a href="{html.escape(problematic_1d_url)}">Проблемные 1д</a>
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a>
<a href="{html.escape(history_url)}">History API</a>
@@ -651,6 +769,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str:
<div class="hero-links">
<a href="/manager/brief">Текущий brief</a>
<a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/problematic?days=1">Проблемные 1д</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a>
</div>
@@ -739,6 +858,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) ->
<div class="hero-links">
<a href="/manager/brief">Текущий brief</a>
<a href="/manager/briefs">История brief</a>
<a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/problematic?days=1">Срез 1д</a>
<a href="/manager/problematic?days=7">Срез 7д</a>
</div>
@@ -808,20 +928,30 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
f"<div class=\"stat\"><div class=\"stat-label\">{html.escape(label)}</div><div class=\"stat-value\">{html.escape(value)}</div></div>"
for label, value in stat_cards
)
tier_labels = {
"critical": "Критический",
"high": "Высокий",
"medium": "Средний",
"low": "Низкий",
}
rows = []
for item in top_changes:
infobase = item.get("infobase")
counterparty = item.get("company")
priority_tier = str(item.get("priority_tier") or "low")
rows.append(
"<tr>"
f"<td><a class=\"inline-link\" href=\"{company_detail_url(str(counterparty), str(infobase) if infobase else None)}\">{html.escape(str(counterparty or '-'))}</a></td>"
f"<td>{html.escape(tier_labels.get(priority_tier, priority_tier))}</td>"
f"<td>{delta_value(item.get('priority_score', 0))}</td>"
f"<td>{html.escape(str(item.get('change_type') or '-'))}</td>"
f"<td>{html.escape(str(item.get('severity_before') or '-'))} -> {html.escape(str(item.get('severity_after') or '-'))}</td>"
f"<td>{delta_value(item.get('score_delta', 0))}</td>"
f"<td>{delta_value(item.get('open_cases_delta', 0))}</td>"
f"<td>{delta_value(item.get('active_locks_delta', 0))}</td>"
f"<td>{delta_value(item.get('forecast_delta', 0))}</td>"
f"<td>{html.escape(str(item.get('priority_reason') or '-'))}</td>"
f"<td>{html.escape(str(item.get('summary') or '-'))}</td>"
"</tr>"
)
@@ -873,6 +1003,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
<div class="hero-links">
<a href="/manager/brief">Текущий brief</a>
<a href="/manager/briefs">История brief</a>
<a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/problematic?days=1">Проблемные 1д</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a>
</div>
@@ -910,17 +1041,20 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
<thead>
<tr>
<th>Компания</th>
<th>Приоритет</th>
<th>Score</th>
<th>Тип</th>
<th>Severity</th>
<th>Score Δ</th>
<th>Cases Δ</th>
<th>Locks Δ</th>
<th>Forecast Δ</th>
<th>Причина приоритета</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody>
{''.join(rows) or '<tr><td colspan="8">Нет выраженных изменений.</td></tr>'}
{''.join(rows) or '<tr><td colspan="11">Нет выраженных изменений.</td></tr>'}
</tbody>
</table>
</article>
@@ -930,6 +1064,155 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
</html>"""
def render_weekly_trend_html(report: dict[str, Any]) -> str:
daily = report.get("daily", [])
latest = report.get("latest") or {}
top_weekly_changes = report.get("top_weekly_changes", [])
def value(v: Any) -> str:
return fmt_number(v)
trend_rows = []
for item in daily:
trend_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('date', '-')))}</td>"
f"<td>{value(item.get('companies_total'))}</td>"
f"<td>{value(item.get('critical_total'))}</td>"
f"<td>{value(item.get('busy_total'))}</td>"
f"<td>{value(item.get('open_cases_total'))}</td>"
f"<td>{value(item.get('detections_total'))}</td>"
f"<td>{value(item.get('activity_30d_total'))}</td>"
f"<td>{value(item.get('activity_forecast_30d_total'))}</td>"
f"<td>{value(item.get('critical_delta_vs_prev_day'))}</td>"
f"<td>{value(item.get('open_cases_delta_vs_prev_day'))}</td>"
"</tr>"
)
change_rows = []
for item in top_weekly_changes:
infobase = item.get("infobase")
company = item.get("company")
change_rows.append(
"<tr>"
f"<td><a class=\"inline-link\" href=\"{company_detail_url(str(company), str(infobase) if infobase else None)}\">{html.escape(str(company or '-'))}</a></td>"
f"<td>{html.escape(str(item.get('priority_tier') or '-'))}</td>"
f"<td>{value(item.get('priority_score'))}</td>"
f"<td>{html.escape(str(item.get('change_type') or '-'))}</td>"
f"<td>{value(item.get('open_cases_delta'))}</td>"
f"<td>{value(item.get('active_locks_delta'))}</td>"
f"<td>{value(item.get('forecast_delta'))}</td>"
f"<td>{html.escape(str(item.get('priority_reason') or '-'))}</td>"
"</tr>"
)
stat_cards = [
("Компаний", value(latest.get("companies_total"))),
("Critical", value(latest.get("critical_total"))),
("Busy", value(latest.get("busy_total"))),
("Кейсы", value(latest.get("open_cases_total"))),
("Detections", value(latest.get("detections_total"))),
("Прогноз 30д", value(latest.get("activity_forecast_30d_total"))),
]
stat_html = "".join(
f"<div class=\"stat\"><div class=\"stat-label\">{html.escape(label)}</div><div class=\"stat-value\">{html.escape(val)}</div></div>"
for label, val in stat_cards
)
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="300">
<title>1C Weekly Trends</title>
<style>
:root {{ --bg:#f4f1ea; --paper:#fffdf8; --ink:#1c1a17; --muted:#6b655c; --line:#d8d0c4; --accent:#005f73; --shadow:0 14px 40px rgba(28,26,23,.08); }}
* {{ box-sizing:border-box; }}
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",system-ui,sans-serif; color:var(--ink); background:linear-gradient(180deg,#faf7f2 0%,var(--bg) 100%); }}
.shell {{ max-width:1480px; margin:0 auto; padding:28px 22px 60px; }}
.hero {{ background:linear-gradient(135deg,rgba(0,95,115,.94),rgba(10,77,104,.92)); color:#f8fbfc; border-radius:24px; padding:28px 30px; box-shadow:var(--shadow); }}
.hero h1 {{ margin:0 0 10px; font-size:clamp(28px,4vw,42px); }}
.hero p {{ margin:0 0 14px; color:rgba(248,251,252,.88); line-height:1.5; }}
.hero-links {{ display:flex; gap:10px; flex-wrap:wrap; }}
.hero-links a {{ text-decoration:none; color:#f8fbfc; border:1px solid rgba(248,251,252,.28); padding:9px 12px; border-radius:999px; font-size:14px; }}
.panel {{ margin-top:22px; background:var(--paper); border:1px solid var(--line); border-radius:22px; padding:22px; box-shadow:var(--shadow); }}
.stats {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }}
.stat {{ padding:16px; border-radius:18px; background:linear-gradient(180deg,#fff 0%,#f7f4ee 100%); border:1px solid var(--line); }}
.stat-label {{ color:var(--muted); font-size:13px; margin-bottom:8px; }}
.stat-value {{ font-size:24px; font-weight:700; letter-spacing:-.03em; }}
table {{ width:100%; border-collapse:collapse; font-size:14px; }}
th,td {{ text-align:left; padding:10px 8px; border-bottom:1px solid var(--line); vertical-align:top; }}
th {{ color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.06em; }}
.inline-link {{ color:var(--accent); text-decoration:none; font-weight:600; }}
.inline-link:hover {{ text-decoration:underline; }}
@media (max-width:1100px) {{ .stats {{ grid-template-columns:repeat(2,minmax(0,1fr)); }} }}
@media (max-width:640px) {{ .shell {{ padding:16px 14px 40px; }} .hero {{ padding:22px 18px; }} .stats {{ grid-template-columns:1fr; }} }}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<h1>Недельный тренд портфеля</h1>
<p>Период: {html.escape(str(report.get('period_start', '-')))} -> {html.escape(str(report.get('period_end', '-')))}. Страница показывает тренд по истории executive brief и недельный рейтинг проблемных компаний.</p>
<div class="hero-links">
<a href="/manager/brief">Текущий brief</a>
<a href="/manager/changes">Что изменилось</a>
<a href="/manager/briefs">История brief</a>
<a href="/manager/problematic?days=7">Проблемные 7д</a>
</div>
</section>
<section class="panel">
<h2>Текущее состояние по последнему дневному срезу</h2>
<div class="stats">{stat_html}</div>
</section>
<section class="panel">
<h2>Дневной тренд</h2>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Компаний</th>
<th>Critical</th>
<th>Busy</th>
<th>Кейсы</th>
<th>Detections</th>
<th>Активность 30д</th>
<th>Прогноз 30д</th>
<th>Critical Δ</th>
<th>Cases Δ</th>
</tr>
</thead>
<tbody>
{''.join(trend_rows) or '<tr><td colspan="10">Недостаточно истории для недельного тренда.</td></tr>'}
</tbody>
</table>
</section>
<section class="panel">
<h2>Недельный рейтинг приоритетов</h2>
<table>
<thead>
<tr>
<th>Компания</th>
<th>Приоритет</th>
<th>Priority score</th>
<th>Тип</th>
<th>Cases Δ</th>
<th>Locks Δ</th>
<th>Forecast Δ</th>
<th>Причина</th>
</tr>
</thead>
<tbody>
{''.join(change_rows) or '<tr><td colspan="8">За выбранный период заметных изменений нет.</td></tr>'}
</tbody>
</table>
</section>
</main>
</body>
</html>"""
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
@@ -1151,6 +1434,11 @@ def manager_brief_delta_latest() -> dict[str, Any]:
}
@app.get("/api/1/analytics-1c/manager/trends/weekly")
def manager_weekly_trends(days: int = Query(default=7, ge=2, le=30)) -> dict[str, Any]:
return build_weekly_trend_report(load_brief_history_payloads(limit=400), days=days)
@app.get("/api/1/analytics-1c/companies/problematic")
def problematic_companies_api(
days: int = Query(default=7, ge=1, le=30),
@@ -1170,6 +1458,11 @@ def manager_brief_delta_view() -> str:
return render_brief_delta_html(load_latest_manager_brief())
@app.get("/manager/trends/weekly", response_class=HTMLResponse)
def manager_weekly_trends_view(days: int = Query(default=7, ge=2, le=30)) -> str:
return render_weekly_trend_html(build_weekly_trend_report(load_brief_history_payloads(limit=400), days=days))
@app.get("/manager/briefs", response_class=HTMLResponse)
def manager_brief_history_view(limit: int = Query(default=40, ge=1, le=200)) -> str:
return render_brief_history_html(load_brief_history_records(limit))
@@ -1384,6 +1677,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
<nav class="hero-links">
<a href="/manager/brief">К портфелю</a>
<a href="/manager/changes">Что изменилось</a>
<a href="/manager/trends/weekly">Неделя</a>
<a href="/manager/briefs">История brief</a>
<a href="/manager/problematic?days=7">Проблемные компании</a>
<a href="{html.escape(summary_url)}">JSON summary</a>
+59 -2
View File
@@ -117,6 +117,60 @@ def severity_rank(value: str | None) -> int:
}.get((value or "none").lower(), 0)
def rank_top_change(change: dict[str, Any]) -> dict[str, Any]:
severity_after = str(change.get("severity_after") or "none")
severity_before = str(change.get("severity_before") or "none")
severity_delta = max(severity_rank(severity_after) - severity_rank(severity_before), 0)
cases_delta = max(int(change.get("open_cases_delta") or 0), 0)
detections_delta = max(int(change.get("detections_delta") or 0), 0)
locks_delta = max(int(change.get("active_locks_delta") or 0), 0)
score_delta = max(int(change.get("score_delta") or 0), 0)
forecast_before = float(change.get("forecast_before") or 0)
forecast_delta = float(change.get("forecast_delta") or 0)
forecast_drop_pct = 0.0
if forecast_delta < 0:
forecast_drop_pct = abs(forecast_delta) / max(abs(forecast_before), 1.0) * 100.0
priority_score = round(
float(change.get("significance") or 0)
+ severity_delta * 24
+ cases_delta * 8
+ detections_delta * 5
+ locks_delta * 10
+ score_delta * 0.8
+ forecast_drop_pct * 0.6,
2,
)
reasons: list[str] = []
if severity_delta > 0:
reasons.append("рост severity")
if cases_delta > 0:
reasons.append(f"рост кейсов +{cases_delta}")
if detections_delta > 0:
reasons.append(f"рост detections +{detections_delta}")
if locks_delta > 0:
reasons.append(f"рост блокировок +{locks_delta}")
if forecast_drop_pct >= 10:
reasons.append(f"просадка прогноза {round(forecast_drop_pct, 1)}%")
if change.get("registry_match_mode") == "manual":
reasons.append("manual match")
if priority_score >= 140:
priority_tier = "critical"
elif priority_score >= 85:
priority_tier = "high"
elif priority_score >= 40:
priority_tier = "medium"
else:
priority_tier = "low"
change["priority_score"] = priority_score
change["priority_tier"] = priority_tier
change["priority_reason"] = ", ".join(reasons[:4]) if reasons else "слабый сдвиг без явного триггера"
return change
def load_previous_artifact(state_dir: Path) -> dict[str, Any] | None:
latest_path = state_dir / "latest.json"
if not latest_path.exists():
@@ -473,7 +527,8 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
continue
top_changes.append(
{
rank_top_change(
{
"infobase": current_item.get("infobase"),
"company": current_item.get("counterparty"),
"normalized_counterparty": current_item.get("normalized_counterparty"),
@@ -498,7 +553,8 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
"forecast_after": round(forecast_after, 2),
"forecast_delta": forecast_delta,
"significance": round(significance, 2),
}
}
)
)
entered_watchlist = sorted(
@@ -510,6 +566,7 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
top_changes.sort(
key=lambda item: (
float(item.get("priority_score") or 0),
float(item.get("significance") or 0),
int(item.get("score_after") or 0),
int(item.get("open_cases_after") or 0),
@@ -148,6 +148,63 @@ class CompanyIntelligenceApiTests(unittest.TestCase):
self.assertIn("Ключевые изменения", html_page)
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
def test_build_weekly_trend_report_and_render(self) -> None:
payloads = [
{
"generated_at": "2026-05-21T12:00:00+00:00",
"context": {
"portfolio_summary": {
"companies_total": 41,
"critical_total": 39,
"high_total": 2,
"busy_total": 40,
"open_cases_total": 100,
"detections_total": 100,
"activity_30d_total": 1000.0,
"activity_forecast_30d_total": 2000.0,
},
"delta": {"top_changes": []},
},
},
{
"generated_at": "2026-05-22T12:00:00+00:00",
"context": {
"portfolio_summary": {
"companies_total": 41,
"critical_total": 41,
"high_total": 0,
"busy_total": 41,
"open_cases_total": 135,
"detections_total": 130,
"activity_30d_total": 1200.0,
"activity_forecast_30d_total": 2500.0,
},
"delta": {
"top_changes": [
{
"infobase": "ФЕЛИЦТ ГРУПП 2026",
"company": "ФЕЛИЦТ ГРУПП 2026",
"change_type": "cases_up",
"open_cases_delta": 5,
"active_locks_delta": 2,
"forecast_delta": -20.0,
"priority_score": 180,
"priority_tier": "critical",
"priority_reason": "рост кейсов +5, рост блокировок +2",
}
]
},
},
},
]
report = api.build_weekly_trend_report(payloads, days=7)
self.assertEqual(len(report["daily"]), 2)
self.assertEqual(report["top_weekly_changes"][0]["company"], "ФЕЛИЦТ ГРУПП 2026")
html_page = api.render_weekly_trend_html(report)
self.assertIn("Недельный тренд портфеля", html_page)
self.assertIn("Недельный рейтинг приоритетов", html_page)
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -133,6 +133,8 @@ class ManagerBriefTests(unittest.TestCase):
self.assertEqual(delta["summary"]["critical_total_delta"], 2)
self.assertIn("ФЕЛИЦТ ГРУПП 2026", delta["new_critical"])
self.assertEqual(delta["top_changes"][0]["change_type"], "severity_up")
self.assertIn("priority_score", delta["top_changes"][0])
self.assertIn("priority_tier", delta["top_changes"][0])
if __name__ == "__main__":