feat(1c): add brief history and problematic company pages
This commit is contained in:
@@ -68,6 +68,36 @@ 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 load_brief_history_records(limit: int = 20) -> 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"))
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"generated_at": payload.get("generated_at"),
|
||||||
|
"render_mode": payload.get("render_mode"),
|
||||||
|
"model": payload.get("model"),
|
||||||
|
"headline": payload.get("brief", {}).get("headline", ""),
|
||||||
|
"path": path.name,
|
||||||
|
"brief": payload.get("brief", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def load_brief_history_record(name: str) -> dict[str, Any]:
|
||||||
|
safe_name = Path(name).name
|
||||||
|
if not safe_name.endswith(".json"):
|
||||||
|
safe_name += ".json"
|
||||||
|
path = manager_brief_state_dir() / "history" / safe_name
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="manager brief history record not found")
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def grafana_company_dashboard_url() -> str:
|
def grafana_company_dashboard_url() -> str:
|
||||||
return os.getenv(
|
return os.getenv(
|
||||||
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
|
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
|
||||||
@@ -103,6 +133,11 @@ def company_detail_url(counterparty: str, infobase: str | None = None) -> str:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def manager_brief_history_html_url(name: str) -> str:
|
||||||
|
safe_name = Path(name).name
|
||||||
|
return f"/manager/briefs/{quote(safe_name)}"
|
||||||
|
|
||||||
|
|
||||||
def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
||||||
brief = payload.get("brief", {})
|
brief = payload.get("brief", {})
|
||||||
context = payload.get("context", {})
|
context = payload.get("context", {})
|
||||||
@@ -115,6 +150,9 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
|||||||
render_mode = payload.get("render_mode", "unknown")
|
render_mode = payload.get("render_mode", "unknown")
|
||||||
generated_at = payload.get("generated_at", "")
|
generated_at = payload.get("generated_at", "")
|
||||||
history_url = "/api/1/analytics-1c/manager/brief/history"
|
history_url = "/api/1/analytics-1c/manager/brief/history"
|
||||||
|
history_html_url = "/manager/briefs"
|
||||||
|
problematic_1d_url = "/manager/problematic?days=1"
|
||||||
|
problematic_7d_url = "/manager/problematic?days=7"
|
||||||
json_url = "/api/1/analytics-1c/manager/brief/latest"
|
json_url = "/api/1/analytics-1c/manager/brief/latest"
|
||||||
md_url = "/api/1/analytics-1c/manager/brief/latest.md"
|
md_url = "/api/1/analytics-1c/manager/brief/latest.md"
|
||||||
grafana_url = grafana_company_dashboard_url()
|
grafana_url = grafana_company_dashboard_url()
|
||||||
@@ -409,7 +447,10 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
|||||||
<nav class="hero-links">
|
<nav class="hero-links">
|
||||||
<a href="{html.escape(json_url)}">JSON</a>
|
<a href="{html.escape(json_url)}">JSON</a>
|
||||||
<a href="{html.escape(md_url)}">Markdown</a>
|
<a href="{html.escape(md_url)}">Markdown</a>
|
||||||
<a href="{html.escape(history_url)}">History</a>
|
<a href="{html.escape(history_html_url)}">История brief</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>
|
||||||
<a href="{html.escape(grafana_url)}">Grafana</a>
|
<a href="{html.escape(grafana_url)}">Grafana</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -494,6 +535,222 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str:
|
|||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def problematic_companies(days: int = 7, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
client = ch_client()
|
||||||
|
sql = f"""
|
||||||
|
WITH recent AS (
|
||||||
|
SELECT
|
||||||
|
infobase,
|
||||||
|
counterparty,
|
||||||
|
max(generated_at) AS latest_signal_at,
|
||||||
|
max(score) AS max_score,
|
||||||
|
sum(score) AS total_score,
|
||||||
|
count() AS signals_total,
|
||||||
|
countIf(severity = 'critical') AS critical_total,
|
||||||
|
countIf(severity = 'high') AS high_total,
|
||||||
|
argMax(severity, tuple(score, generated_at)) AS top_severity,
|
||||||
|
argMax(signal_type, tuple(score, generated_at)) AS top_signal_type,
|
||||||
|
argMax(summary, tuple(score, generated_at)) AS top_summary
|
||||||
|
FROM analytics_1c.company_health_signals
|
||||||
|
WHERE generated_at >= now() - INTERVAL {int(days)} DAY
|
||||||
|
GROUP BY infobase, counterparty
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.infobase,
|
||||||
|
p.counterparty,
|
||||||
|
p.company_name,
|
||||||
|
p.normalized_counterparty,
|
||||||
|
p.registry_match_mode,
|
||||||
|
p.registry_assignee_name,
|
||||||
|
p.registry_status,
|
||||||
|
p.signal_severity,
|
||||||
|
p.signal_score,
|
||||||
|
p.amount_30d,
|
||||||
|
p.amount_forecast_30d,
|
||||||
|
p.current_status,
|
||||||
|
p.active_locks,
|
||||||
|
p.open_cases_total,
|
||||||
|
p.detections_total,
|
||||||
|
r.latest_signal_at,
|
||||||
|
r.max_score,
|
||||||
|
r.total_score,
|
||||||
|
r.signals_total,
|
||||||
|
r.critical_total,
|
||||||
|
r.high_total,
|
||||||
|
r.top_severity,
|
||||||
|
r.top_signal_type,
|
||||||
|
r.top_summary
|
||||||
|
FROM recent AS r
|
||||||
|
INNER JOIN analytics_1c.v_company_portfolio_overview AS p
|
||||||
|
ON p.infobase = r.infobase
|
||||||
|
AND p.counterparty = r.counterparty
|
||||||
|
ORDER BY r.max_score DESC, r.signals_total DESC, p.amount_30d DESC, p.counterparty
|
||||||
|
LIMIT {int(limit)}
|
||||||
|
"""
|
||||||
|
return rows_to_dict(client.query(sql))
|
||||||
|
|
||||||
|
|
||||||
|
def render_brief_history_html(items: list[dict[str, Any]]) -> str:
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><a class=\"inline-link\" href=\"{manager_brief_history_html_url(str(item.get('path', '')))}\">{html.escape(str(item.get('generated_at', '-')))}</a></td>"
|
||||||
|
f"<td>{html.escape(str(item.get('render_mode', '-')))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('model') or '-'))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('headline') or '-'))}</td>"
|
||||||
|
f"<td><a class=\"inline-link\" href=\"/api/1/analytics-1c/manager/brief/history/{quote(str(item.get('path', '')))}\">JSON</a></td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
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 Brief History</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #f4f1ea; --paper: #fffdf8; --ink: #1c1a17; --muted: #6b655c; --line: #d8d0c4; --accent: #005f73; --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: 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 h1 {{ margin: 0 0 10px; font-size: clamp(28px, 4vw, 42px); }}
|
||||||
|
.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; }}
|
||||||
|
.panel {{ margin-top: 22px; background: var(--paper); border: 1px solid var(--line); border-radius: 22px; padding: 22px; box-shadow: var(--shadow); }}
|
||||||
|
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; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>История executive brief</h1>
|
||||||
|
<div class="hero-links">
|
||||||
|
<a href="/manager/brief">Текущий brief</a>
|
||||||
|
<a href="/manager/problematic?days=1">Проблемные 1д</a>
|
||||||
|
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Сформировано</th>
|
||||||
|
<th>Режим</th>
|
||||||
|
<th>Модель</th>
|
||||||
|
<th>Headline</th>
|
||||||
|
<th>Raw</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(rows) or '<tr><td colspan="5">История пока пуста.</td></tr>'}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> str:
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><a class=\"inline-link\" href=\"{company_detail_url(str(item.get('counterparty', '-')), str(item.get('infobase', '')) if item.get('infobase') else None)}\">{html.escape(str(item.get('counterparty', '-')))}</a></td>"
|
||||||
|
f"<td>{html.escape(str(item.get('normalized_counterparty') or '-'))}</td>"
|
||||||
|
f"<td>{severity_badge(str(item.get('top_severity') or item.get('signal_severity') or 'none'))}</td>"
|
||||||
|
f"<td>{fmt_number(item.get('max_score'))}</td>"
|
||||||
|
f"<td>{fmt_number(item.get('signals_total'))}</td>"
|
||||||
|
f"<td>{fmt_number(item.get('critical_total'))}</td>"
|
||||||
|
f"<td>{fmt_number(item.get('amount_30d'))}</td>"
|
||||||
|
f"<td>{fmt_number(item.get('amount_forecast_30d'))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('top_signal_type') or '-'))}</td>"
|
||||||
|
f"<td>{html.escape(str(item.get('top_summary') or '-'))}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
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 Problem Companies</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--bg: #f4f1ea; --paper: #fffdf8; --ink: #1c1a17; --muted: #6b655c; --line: #d8d0c4; --accent: #005f73; --shadow: 0 14px 40px rgba(28, 26, 23, 0.08);
|
||||||
|
--critical: #9b2226; --high: #bb3e03; --medium: #ca6702; --low: #4d7c0f; --none: #687076;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{ margin: 0; font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif; background: linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%); color: var(--ink); }}
|
||||||
|
.shell {{ max-width: 1500px; 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 h1 {{ margin: 0 0 10px; font-size: clamp(28px, 4vw, 42px); }}
|
||||||
|
.hero p {{ margin: 0 0 14px; line-height: 1.5; color: rgba(248,251,252,0.88); }}
|
||||||
|
.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; }}
|
||||||
|
.panel {{ margin-top: 22px; background: var(--paper); border: 1px solid var(--line); border-radius: 22px; padding: 22px; box-shadow: var(--shadow); }}
|
||||||
|
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; }}
|
||||||
|
.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; 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); }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Проблемные компании за {days} {('день' if days == 1 else 'дней')}</h1>
|
||||||
|
<p>Список собран по live company signals. В приоритете max score, плотность сигналов и общий вес проблемного контура.</p>
|
||||||
|
<div class="hero-links">
|
||||||
|
<a href="/manager/brief">Текущий brief</a>
|
||||||
|
<a href="/manager/briefs">История brief</a>
|
||||||
|
<a href="/manager/problematic?days=1">Срез 1д</a>
|
||||||
|
<a href="/manager/problematic?days=7">Срез 7д</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Компания</th>
|
||||||
|
<th>Нормализация</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Max score</th>
|
||||||
|
<th>Signals</th>
|
||||||
|
<th>Critical</th>
|
||||||
|
<th>Активность 30д</th>
|
||||||
|
<th>Прогноз 30д</th>
|
||||||
|
<th>Top signal</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(rows) or '<tr><td colspan="10">Нет сигналов за выбранный период.</td></tr>'}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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")
|
||||||
|
|
||||||
|
|
||||||
@@ -695,29 +952,47 @@ def manager_brief_latest_markdown() -> str:
|
|||||||
|
|
||||||
@app.get("/api/1/analytics-1c/manager/brief/history")
|
@app.get("/api/1/analytics-1c/manager/brief/history")
|
||||||
def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[str, Any]:
|
def manager_brief_history(limit: int = Query(default=20, ge=1, le=200)) -> dict[str, Any]:
|
||||||
history_dir = manager_brief_state_dir() / "history"
|
items = load_brief_history_records(limit)
|
||||||
if not history_dir.exists():
|
|
||||||
return {"items": [], "count": 0}
|
|
||||||
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"))
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"generated_at": payload.get("generated_at"),
|
|
||||||
"render_mode": payload.get("render_mode"),
|
|
||||||
"model": payload.get("model"),
|
|
||||||
"headline": payload.get("brief", {}).get("headline", ""),
|
|
||||||
"path": path.name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"items": items, "count": len(items)}
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/1/analytics-1c/manager/brief/history/{name}")
|
||||||
|
def manager_brief_history_record(name: str) -> dict[str, Any]:
|
||||||
|
return load_brief_history_record(name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/1/analytics-1c/companies/problematic")
|
||||||
|
def problematic_companies_api(
|
||||||
|
days: int = Query(default=7, ge=1, le=30),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
items = problematic_companies(days=days, limit=limit)
|
||||||
|
return {"items": items, "count": len(items), "days": days}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/manager/brief", response_class=HTMLResponse)
|
@app.get("/manager/brief", response_class=HTMLResponse)
|
||||||
def manager_brief_view() -> str:
|
def manager_brief_view() -> str:
|
||||||
return render_manager_brief_html(load_latest_manager_brief())
|
return render_manager_brief_html(load_latest_manager_brief())
|
||||||
|
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manager/briefs/{name}", response_class=HTMLResponse)
|
||||||
|
def manager_brief_history_detail_view(name: str) -> str:
|
||||||
|
return render_manager_brief_html(load_brief_history_record(name))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manager/problematic", response_class=HTMLResponse)
|
||||||
|
def manager_problematic_companies_view(
|
||||||
|
days: int = Query(default=7, ge=1, le=30),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> str:
|
||||||
|
return render_problematic_companies_html(problematic_companies(days=days, limit=limit), days)
|
||||||
|
|
||||||
|
|
||||||
def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | None = None) -> str:
|
def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | None = None) -> str:
|
||||||
card = summary_payload["card"]
|
card = summary_payload["card"]
|
||||||
company_state = summary_payload.get("company_state") or {}
|
company_state = summary_payload.get("company_state") or {}
|
||||||
@@ -908,6 +1183,8 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
|
|||||||
</div>
|
</div>
|
||||||
<nav class="hero-links">
|
<nav class="hero-links">
|
||||||
<a href="/manager/brief">К портфелю</a>
|
<a href="/manager/brief">К портфелю</a>
|
||||||
|
<a href="/manager/briefs">История brief</a>
|
||||||
|
<a href="/manager/problematic?days=7">Проблемные компании</a>
|
||||||
<a href="{html.escape(summary_url)}">JSON summary</a>
|
<a href="{html.escape(summary_url)}">JSON summary</a>
|
||||||
<a href="{html.escape(forecast_url)}">JSON forecast</a>
|
<a href="{html.escape(forecast_url)}">JSON forecast</a>
|
||||||
<a href="{html.escape(timeline_url)}">JSON timeline</a>
|
<a href="{html.escape(timeline_url)}">JSON timeline</a>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
sys.modules.setdefault("clickhouse_connect", types.SimpleNamespace(get_client=lambda **_: None))
|
||||||
|
sys.modules.setdefault("uvicorn", types.SimpleNamespace(run=lambda *args, **kwargs: None))
|
||||||
|
|
||||||
|
if "fastapi" not in sys.modules:
|
||||||
|
fastapi_mod = types.ModuleType("fastapi")
|
||||||
|
|
||||||
|
class DummyFastAPI:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
def decorator(fn):
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
class DummyHTTPException(Exception):
|
||||||
|
def __init__(self, status_code: int, detail: str):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
def dummy_query(default=None, **kwargs):
|
||||||
|
return default
|
||||||
|
|
||||||
|
fastapi_mod.FastAPI = DummyFastAPI
|
||||||
|
fastapi_mod.HTTPException = DummyHTTPException
|
||||||
|
fastapi_mod.Query = dummy_query
|
||||||
|
sys.modules["fastapi"] = fastapi_mod
|
||||||
|
|
||||||
|
responses_mod = types.ModuleType("fastapi.responses")
|
||||||
|
|
||||||
|
class DummyResponse:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
responses_mod.HTMLResponse = DummyResponse
|
||||||
|
responses_mod.PlainTextResponse = DummyResponse
|
||||||
|
responses_mod.Response = DummyResponse
|
||||||
|
sys.modules["fastapi.responses"] = responses_mod
|
||||||
|
|
||||||
|
import company_intelligence_api as api
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyIntelligenceApiTests(unittest.TestCase):
|
||||||
|
def test_render_problematic_companies_html(self) -> None:
|
||||||
|
html_page = api.render_problematic_companies_html(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"counterparty": "ФЕЛИЦТ ГРУПП 2026",
|
||||||
|
"infobase": "ФЕЛИЦТ ГРУПП 2026",
|
||||||
|
"normalized_counterparty": "ФЕЛИЦТ ГРУПП",
|
||||||
|
"top_severity": "critical",
|
||||||
|
"max_score": 95,
|
||||||
|
"signals_total": 12,
|
||||||
|
"critical_total": 5,
|
||||||
|
"amount_30d": 270.68,
|
||||||
|
"amount_forecast_30d": 8120.4,
|
||||||
|
"top_signal_type": "open_cases",
|
||||||
|
"top_summary": "Есть открытые кейсы",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
days=7,
|
||||||
|
)
|
||||||
|
self.assertIn("Проблемные компании за 7 дней", html_page)
|
||||||
|
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
|
||||||
|
self.assertIn("/manager/company/", html_page)
|
||||||
|
|
||||||
|
def test_load_brief_history_records_and_detail(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"generated_at": "2026-05-22T12:00:00+00:00",
|
||||||
|
"render_mode": "codex",
|
||||||
|
"model": "codex",
|
||||||
|
"brief": {"headline": "Тестовый brief"},
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state_dir = Path(tmp)
|
||||||
|
history_dir = state_dir / "history"
|
||||||
|
history_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(history_dir / "20260522T120000Z.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
old = os.environ.get("AW_1C_MANAGER_BRIEF_STATE_DIR")
|
||||||
|
os.environ["AW_1C_MANAGER_BRIEF_STATE_DIR"] = str(state_dir)
|
||||||
|
try:
|
||||||
|
items = api.load_brief_history_records(limit=5)
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(items[0]["headline"], "Тестовый brief")
|
||||||
|
detail = api.load_brief_history_record("20260522T120000Z.json")
|
||||||
|
self.assertEqual(detail["brief"]["headline"], "Тестовый brief")
|
||||||
|
finally:
|
||||||
|
if old is None:
|
||||||
|
os.environ.pop("AW_1C_MANAGER_BRIEF_STATE_DIR", None)
|
||||||
|
else:
|
||||||
|
os.environ["AW_1C_MANAGER_BRIEF_STATE_DIR"] = old
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user