feat(1c): add browser executive brief page
This commit is contained in:
@@ -2,17 +2,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import clickhouse_connect
|
import clickhouse_connect
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -66,6 +68,424 @@ def load_latest_manager_brief() -> dict[str, Any]:
|
|||||||
return json.loads(latest_path.read_text(encoding="utf-8"))
|
return json.loads(latest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def grafana_company_dashboard_url() -> str:
|
||||||
|
return os.getenv(
|
||||||
|
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
|
||||||
|
"http://10.10.10.11:3000/d/1c-file-companies/1c-file-company-intelligence",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_number(value: Any) -> str:
|
||||||
|
if value is None or value == "":
|
||||||
|
return "-"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f"{value:,.2f}".replace(",", " ").replace(".", ",")
|
||||||
|
if isinstance(value, int):
|
||||||
|
return f"{value:,}".replace(",", " ")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def severity_badge(severity: str) -> str:
|
||||||
|
tone = {
|
||||||
|
"critical": "critical",
|
||||||
|
"high": "high",
|
||||||
|
"medium": "medium",
|
||||||
|
"low": "low",
|
||||||
|
"none": "none",
|
||||||
|
}.get((severity or "").lower(), "none")
|
||||||
|
return f'<span class="badge badge-{tone}">{html.escape(severity or "none")}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
||||||
|
brief = payload.get("brief", {})
|
||||||
|
context = payload.get("context", {})
|
||||||
|
summary = context.get("portfolio_summary", {})
|
||||||
|
freshness = context.get("freshness", [])
|
||||||
|
top_risks = brief.get("top_risks", [])
|
||||||
|
top_forecasts = brief.get("top_forecasts", [])
|
||||||
|
actions = brief.get("actions", [])
|
||||||
|
caveats = brief.get("caveats", [])
|
||||||
|
render_mode = payload.get("render_mode", "unknown")
|
||||||
|
generated_at = payload.get("generated_at", "")
|
||||||
|
history_url = "/api/1/analytics-1c/manager/brief/history"
|
||||||
|
json_url = "/api/1/analytics-1c/manager/brief/latest"
|
||||||
|
md_url = "/api/1/analytics-1c/manager/brief/latest.md"
|
||||||
|
grafana_url = grafana_company_dashboard_url()
|
||||||
|
|
||||||
|
freshness_rows = []
|
||||||
|
for item in freshness:
|
||||||
|
status = "stale" if item.get("stale") else "fresh"
|
||||||
|
freshness_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(str(item.get('source', '')))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('latest_ts', '-')))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('lag_hours', '-')))}</td>"
|
||||||
|
f"<td><span class=\"freshness freshness-{status}\">{'просрочен' if status == 'stale' else 'свежий'}</span></td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
risk_cards = []
|
||||||
|
for item in top_risks:
|
||||||
|
risk_cards.append(
|
||||||
|
"<article class=\"stack-card\">"
|
||||||
|
f"<div class=\"stack-card-head\"><h3>{html.escape(item.get('company', '-'))}</h3>{severity_badge(item.get('severity', ''))}</div>"
|
||||||
|
f"<p class=\"stack-card-body\">{html.escape(item.get('reason', '-'))}</p>"
|
||||||
|
f"<p class=\"stack-card-action\"><strong>Действие:</strong> {html.escape(item.get('recommended_action', '-'))}</p>"
|
||||||
|
"</article>"
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast_cards = []
|
||||||
|
for item in top_forecasts:
|
||||||
|
company = item.get("company", "-")
|
||||||
|
forecast_cards.append(
|
||||||
|
"<article class=\"stack-card\">"
|
||||||
|
f"<div class=\"stack-card-head\"><h3>{html.escape(company)}</h3></div>"
|
||||||
|
f"<p class=\"metric-line\"><strong>Прогноз 30д:</strong> {html.escape(item.get('forecast_30d', '-'))}</p>"
|
||||||
|
f"<p class=\"stack-card-body\">{html.escape(item.get('interpretation', '-'))}</p>"
|
||||||
|
f"<a class=\"inline-link\" href=\"/api/1/analytics-1c/companies/{quote(company)}/summary\">Карточка компании</a>"
|
||||||
|
"</article>"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in brief.get("summary", []))
|
||||||
|
action_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in actions)
|
||||||
|
caveat_items = "\n".join(f"<li>{html.escape(str(item))}</li>" for item in caveats)
|
||||||
|
|
||||||
|
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 Executive Brief</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--paper: #fffdf8;
|
||||||
|
--ink: #1c1a17;
|
||||||
|
--muted: #6b655c;
|
||||||
|
--line: #d8d0c4;
|
||||||
|
--accent: #005f73;
|
||||||
|
--accent-soft: #d9eef2;
|
||||||
|
--critical: #9b2226;
|
||||||
|
--high: #bb3e03;
|
||||||
|
--medium: #ca6702;
|
||||||
|
--low: #4d7c0f;
|
||||||
|
--none: #687076;
|
||||||
|
--fresh: #1d6f42;
|
||||||
|
--stale: #9b2226;
|
||||||
|
--shadow: 0 14px 40px rgba(28, 26, 23, 0.08);
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
font-family: \"IBM Plex Sans\", \"Segoe UI\", system-ui, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(0, 95, 115, 0.08), transparent 28%),
|
||||||
|
linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}}
|
||||||
|
.shell {{
|
||||||
|
max-width: 1380px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 22px 60px;
|
||||||
|
}}
|
||||||
|
.hero {{
|
||||||
|
background: linear-gradient(135deg, rgba(0,95,115,0.94), rgba(10,77,104,0.92));
|
||||||
|
color: #f8fbfc;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}}
|
||||||
|
.hero-top {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}}
|
||||||
|
.hero h1 {{
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: clamp(28px, 4vw, 44px);
|
||||||
|
line-height: 1.05;
|
||||||
|
}}
|
||||||
|
.hero-meta {{
|
||||||
|
color: rgba(248, 251, 252, 0.82);
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
.hero-links {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}}
|
||||||
|
.hero-links a {{
|
||||||
|
text-decoration: none;
|
||||||
|
color: #f8fbfc;
|
||||||
|
border: 1px solid rgba(248, 251, 252, 0.28);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}}
|
||||||
|
.hero-links a:hover {{
|
||||||
|
background: rgba(248, 251, 252, 0.12);
|
||||||
|
}}
|
||||||
|
.grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}}
|
||||||
|
.panel h2 {{
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 21px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}}
|
||||||
|
.panel h3 {{
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}}
|
||||||
|
.span-12 {{ grid-column: span 12; }}
|
||||||
|
.span-8 {{ grid-column: span 8; }}
|
||||||
|
.span-6 {{ grid-column: span 6; }}
|
||||||
|
.span-4 {{ grid-column: span 4; }}
|
||||||
|
.span-3 {{ grid-column: span 3; }}
|
||||||
|
.summary-list,
|
||||||
|
.action-list,
|
||||||
|
.caveat-list {{
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 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: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}}
|
||||||
|
.stack {{
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}}
|
||||||
|
.stack-card {{
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: #fffdfa;
|
||||||
|
}}
|
||||||
|
.stack-card-head {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}}
|
||||||
|
.stack-card-body,
|
||||||
|
.stack-card-action,
|
||||||
|
.metric-line {{
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}}
|
||||||
|
.stack-card-action {{
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff;
|
||||||
|
}}
|
||||||
|
.badge-critical {{ background: var(--critical); }}
|
||||||
|
.badge-high {{ background: var(--high); }}
|
||||||
|
.badge-medium {{ background: var(--medium); }}
|
||||||
|
.badge-low {{ background: var(--low); }}
|
||||||
|
.badge-none {{ background: var(--none); }}
|
||||||
|
.freshness {{
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}}
|
||||||
|
.freshness-fresh {{
|
||||||
|
background: rgba(29, 111, 66, 0.12);
|
||||||
|
color: var(--fresh);
|
||||||
|
}}
|
||||||
|
.freshness-stale {{
|
||||||
|
background: rgba(155, 34, 38, 0.12);
|
||||||
|
color: var(--stale);
|
||||||
|
}}
|
||||||
|
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: 0.06em;
|
||||||
|
}}
|
||||||
|
.inline-link {{
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.inline-link:hover {{
|
||||||
|
text-decoration: underline;
|
||||||
|
}}
|
||||||
|
@media (max-width: 1100px) {{
|
||||||
|
.span-8, .span-6, .span-4, .span-3 {{ grid-column: span 12; }}
|
||||||
|
.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; }}
|
||||||
|
.stack-card-head {{ flex-direction: column; align-items: flex-start; }}
|
||||||
|
th:nth-child(2), td:nth-child(2),
|
||||||
|
th:nth-child(3), td:nth-child(3) {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-top">
|
||||||
|
<div>
|
||||||
|
<div class="hero-meta">AW-rus · 1C Executive Brief · render mode: {html.escape(render_mode)}</div>
|
||||||
|
<h1>{html.escape(brief.get("headline", "Executive brief недоступен"))}</h1>
|
||||||
|
<div class="hero-meta">Сформировано: {html.escape(generated_at)}</div>
|
||||||
|
</div>
|
||||||
|
<nav class="hero-links">
|
||||||
|
<a href="{html.escape(json_url)}">JSON</a>
|
||||||
|
<a href="{html.escape(md_url)}">Markdown</a>
|
||||||
|
<a href="{html.escape(history_url)}">History</a>
|
||||||
|
<a href="{html.escape(grafana_url)}">Grafana</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="panel span-8">
|
||||||
|
<h2>Краткий комментарий</h2>
|
||||||
|
<ol class="summary-list">
|
||||||
|
{summary_items}
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
<article class="panel span-4">
|
||||||
|
<h2>Портфель</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Компаний</div>
|
||||||
|
<div class="stat-value">{fmt_number(summary.get("companies_total"))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Critical</div>
|
||||||
|
<div class="stat-value">{fmt_number(summary.get("critical_total"))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Кейсы</div>
|
||||||
|
<div class="stat-value">{fmt_number(summary.get("open_cases_total"))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Прогноз 30д</div>
|
||||||
|
<div class="stat-value">{fmt_number(summary.get("activity_forecast_30d_total"))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel span-6">
|
||||||
|
<h2>Компании риска</h2>
|
||||||
|
<div class="stack">
|
||||||
|
{''.join(risk_cards) or '<p>Нет данных.</p>'}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel span-6">
|
||||||
|
<h2>Прогноз активности 30 дней</h2>
|
||||||
|
<div class="stack">
|
||||||
|
{''.join(forecast_cards) or '<p>Нет данных.</p>'}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel span-6">
|
||||||
|
<h2>Что проверить руководителю</h2>
|
||||||
|
<ul class="action-list">
|
||||||
|
{action_items}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel span-6">
|
||||||
|
<h2>Ограничения интерпретации</h2>
|
||||||
|
<ul class="caveat-list">
|
||||||
|
{caveat_items}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel span-12">
|
||||||
|
<h2>Свежесть источников</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Последняя отметка</th>
|
||||||
|
<th>Отставание, ч</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(freshness_rows)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
|
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
|
||||||
|
|
||||||
|
|
||||||
@@ -280,6 +700,11 @@ def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[
|
|||||||
return {"items": items, "count": len(items)}
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manager/brief", response_class=HTMLResponse)
|
||||||
|
def manager_brief_view() -> str:
|
||||||
|
return render_manager_brief_html(load_latest_manager_brief())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
uvicorn.run(app, host=args.host, port=args.port)
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ Endpoints:
|
|||||||
- `GET /api/1/analytics-1c/manager/brief/latest`
|
- `GET /api/1/analytics-1c/manager/brief/latest`
|
||||||
- `GET /api/1/analytics-1c/manager/brief/latest.md`
|
- `GET /api/1/analytics-1c/manager/brief/latest.md`
|
||||||
- `GET /api/1/analytics-1c/manager/brief/history`
|
- `GET /api/1/analytics-1c/manager/brief/history`
|
||||||
|
- `GET /manager/brief`
|
||||||
|
|
||||||
|
`/manager/brief` — это human-facing browser page для руководителя:
|
||||||
|
|
||||||
|
- server-rendered HTML;
|
||||||
|
- без frontend build chain и SPA-зависимостей;
|
||||||
|
- опирается на уже сгенерированный `latest.json`;
|
||||||
|
- показывает headline, человеческие комментарии, top risks, top forecasts,
|
||||||
|
действия и свежесть источников;
|
||||||
|
- даёт быстрые ссылки на raw JSON/Markdown и в Grafana.
|
||||||
|
|
||||||
### Executive brief для руководителя
|
### Executive brief для руководителя
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user