feat(1c): add priority ranking and weekly trend page
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user