Files
AWatch-rus/clickhouse-1c/ai/company_intelligence_api.py
T

1508 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import json
import os
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from urllib.parse import quote
import clickhouse_connect
import uvicorn
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Read-only company intelligence API for analytics_1c")
p.add_argument("--host", default=os.getenv("AW_1C_COMPANY_API_HOST", "127.0.0.1"))
p.add_argument("--port", type=int, default=int(os.getenv("AW_1C_COMPANY_API_PORT", "8710")))
return p.parse_args()
def q(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def to_plain(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
return value
def rows_to_dict(result) -> list[dict[str, Any]]:
return [
{name: to_plain(value) for name, value in zip(result.column_names, row)}
for row in result.result_rows
]
def ch_client():
return clickhouse_connect.get_client(
host=os.getenv("CLICKHOUSE_HOST", "localhost"),
port=int(os.getenv("CLICKHOUSE_PORT", "8123")),
username=os.getenv("CLICKHOUSE_USER", "default"),
password=os.getenv("CLICKHOUSE_PASSWORD", ""),
database=os.getenv("CLICKHOUSE_DB", "analytics_1c"),
)
def manager_brief_state_dir() -> Path:
root = Path(os.getenv("AW_1C_ROOT", "/opt/activitywatch/clickhouse-1c"))
configured = os.getenv("AW_1C_MANAGER_BRIEF_STATE_DIR")
if configured:
return Path(configured)
return root / "state" / "manager-brief"
def load_latest_manager_brief() -> dict[str, Any]:
latest_path = manager_brief_state_dir() / "latest.json"
if not latest_path.exists():
raise HTTPException(status_code=404, detail="manager brief not generated yet")
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 extract_delta(payload: dict[str, Any]) -> dict[str, Any]:
delta = payload.get("context", {}).get("delta")
if isinstance(delta, dict):
return delta
return {"available": False, "reason": "delta not present in artifact"}
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 company_detail_url(counterparty: str, infobase: str | None = None) -> str:
base = f"/manager/company/{quote(counterparty)}"
if infobase:
return f"{base}?infobase={quote(infobase)}"
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:
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"
history_html_url = "/manager/briefs"
delta_html_url = "/manager/changes"
problematic_1d_url = "/manager/problematic?days=1"
problematic_7d_url = "/manager/problematic?days=7"
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>"
f"<p class=\"stack-card-action\"><a class=\"inline-link\" href=\"{company_detail_url(item.get('company', '-'))}\">Открыть карточку компании</a></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=\"{company_detail_url(company)}\">Карточка компании</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_html_url)}">История brief</a>
<a href="{html.escape(delta_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>
<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>"""
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 AS infobase,
p.counterparty AS 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)}
"""
items = rows_to_dict(client.query(sql))
for item in items:
if "infobase" not in item and "p.infobase" in item:
item["infobase"] = item["p.infobase"]
if "counterparty" not in item and "p.counterparty" in item:
item["counterparty"] = item["p.counterparty"]
return items
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=\"/manager/briefs/{quote(str(item.get('path', '')))}/changes\">Изменения</a></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/changes">Что изменилось</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>Delta</th>
<th>Raw</th>
</tr>
</thead>
<tbody>
{''.join(rows) or '<tr><td colspan="6">История пока пуста.</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>"""
def render_brief_delta_html(payload: dict[str, Any]) -> str:
delta = extract_delta(payload)
brief = payload.get("brief", {})
if not delta.get("available"):
return f"""<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>1C Brief Changes</title></head><body><main style="max-width:980px;margin:40px auto;font-family:IBM Plex Sans,Segoe UI,sans-serif">
<h1>Что изменилось с прошлого запуска</h1><p>Delta пока недоступна: {html.escape(str(delta.get('reason', 'unknown')))}.</p>
<p><a href="/manager/brief">Вернуться к brief</a></p></main></body></html>"""
summary = delta.get("summary", {})
top_changes = delta.get("top_changes", [])
new_critical = delta.get("new_critical", [])
resolved_critical = delta.get("resolved_critical", [])
entered_watchlist = delta.get("entered_watchlist", [])
left_watchlist = delta.get("left_watchlist", [])
def delta_value(value: Any) -> str:
try:
numeric = float(value)
except (TypeError, ValueError):
return html.escape(str(value))
prefix = "+" if numeric > 0 else ""
if numeric.is_integer():
return prefix + str(int(numeric))
return prefix + f"{numeric:.2f}".replace(".", ",")
stat_cards = [
("Critical", delta_value(summary.get("critical_total_delta", 0))),
("Busy", delta_value(summary.get("busy_total_delta", 0))),
("Кейсы", delta_value(summary.get("open_cases_total_delta", 0))),
("Detections", delta_value(summary.get("detections_total_delta", 0))),
("Активность 30д", delta_value(summary.get("activity_30d_total_delta", 0))),
("Прогноз 30д", delta_value(summary.get("activity_forecast_30d_total_delta", 0))),
]
stat_html = "".join(
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
)
rows = []
for item in top_changes:
infobase = item.get("infobase")
counterparty = item.get("company")
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(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('summary') or '-'))}</td>"
"</tr>"
)
def as_list(items: list[str]) -> str:
if not items:
return "<li>Нет</li>"
return "".join(f"<li>{html.escape(str(item))}</li>" for item in items)
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 Changes</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; }}
.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); }}
.span-12 {{ grid-column:span 12; }} .span-6 {{ grid-column:span 6; }} .span-4 {{ grid-column:span 4; }}
.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; }}
ul {{ margin:0; padding-left:20px; line-height:1.55; }}
@media (max-width:1100px) {{ .span-6,.span-4 {{ 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; }} }}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<h1>Что изменилось с прошлого запуска</h1>
<p>Сравнение between briefs: {html.escape(str(delta.get('previous_generated_at') or '-'))} -> {html.escape(str(delta.get('current_generated_at') or payload.get('generated_at') or '-'))}.</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="grid">
<article class="panel span-12">
<h2>Сводка изменений</h2>
<div class="stats">{stat_html}</div>
</article>
<article class="panel span-6">
<h2>Новые critical</h2>
<ul>{as_list(new_critical)}</ul>
</article>
<article class="panel span-6">
<h2>Вышли из critical</h2>
<ul>{as_list(resolved_critical)}</ul>
</article>
<article class="panel span-6">
<h2>Зашли в watchlist</h2>
<ul>{as_list(entered_watchlist)}</ul>
</article>
<article class="panel span-6">
<h2>Вышли из watchlist</h2>
<ul>{as_list(left_watchlist)}</ul>
</article>
<article class="panel span-12">
<h2>Top changes today</h2>
<table>
<thead>
<tr>
<th>Компания</th>
<th>Тип</th>
<th>Severity</th>
<th>Score Δ</th>
<th>Cases Δ</th>
<th>Locks Δ</th>
<th>Forecast Δ</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody>
{''.join(rows) or '<tr><td colspan="8">Нет выраженных изменений.</td></tr>'}
</tbody>
</table>
</article>
</section>
</main>
</body>
</html>"""
app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(status_code=204)
@app.get("/health")
def health() -> dict[str, Any]:
client = ch_client()
summary = rows_to_dict(
client.query(
"""
SELECT
countIf(counterparty != '') AS documents_with_counterparty,
(SELECT count() FROM analytics_1c.companies) AS companies_total,
(SELECT count() FROM analytics_1c.company_registry) AS registry_rows_total,
(SELECT count() FROM analytics_1c.company_forecasts) AS forecasts_total,
(SELECT count() FROM analytics_1c.company_health_signals) AS health_signals_total
FROM analytics_1c.documents
"""
)
)[0]
return {"status": "ok", "generated_at": datetime.now(UTC).isoformat(), **summary}
@app.get("/api/1/analytics-1c/companies/overview")
def companies_overview(
infobase: str | None = None,
min_signal_score: int = Query(default=0, ge=0, le=100),
limit: int = Query(default=50, ge=1, le=500),
) -> dict[str, Any]:
client = ch_client()
where = [f"signal_score >= {int(min_signal_score)}"]
if infobase:
where.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT
infobase,
organization,
counterparty,
company_name,
normalized_counterparty,
registry_match_mode,
registry_assignee_name,
registry_status,
registry_share_text,
registry_key_contour,
registry_inn,
registry_kpp,
owner_user,
base_path,
current_status,
db_size_bytes,
reglog_size_bytes,
active_locks,
current_activity_score,
last_seen_at,
days_since_last_activity,
docs_7d,
amount_7d,
docs_30d,
amount_30d,
amount_forecast_30d,
docs_forecast_30d,
signal_severity,
signal_score,
top_signal
FROM analytics_1c.v_company_portfolio_overview
WHERE {' AND '.join(where)}
ORDER BY signal_score DESC, amount_30d DESC, counterparty
LIMIT {int(limit)}
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
@app.get("/api/1/analytics-1c/companies/{counterparty}/summary")
def company_summary(counterparty: str, infobase: str | None = None) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT *
FROM analytics_1c.v_company_portfolio_overview
WHERE {' AND '.join(filters)}
ORDER BY last_company_snapshot_at DESC, amount_30d DESC
LIMIT 1
"""
rows = rows_to_dict(client.query(sql))
if not rows:
raise HTTPException(status_code=404, detail="counterparty not found in analytics_1c.v_company_portfolio_overview")
card = rows[0]
forecast_sql = f"""
SELECT metric, horizon_days, baseline_daily, trend_slope, predicted_daily, predicted_total, confidence, note
FROM analytics_1c.v_company_forecasts_current
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY metric, horizon_days
"""
signals_sql = f"""
SELECT generated_at, severity, score, signal_type, summary
FROM analytics_1c.v_company_health_current
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY score DESC, generated_at DESC
"""
timeline_sql = f"""
SELECT last_company_snapshot_at AS ts, infobase, company_name, owner_user, current_status, db_size_bytes, reglog_size_bytes, active_locks, current_activity_score
FROM analytics_1c.v_company_portfolio_overview
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY ts DESC
LIMIT 1
"""
forecasts = rows_to_dict(client.query(forecast_sql))
signals = rows_to_dict(client.query(signals_sql))
company_state = rows_to_dict(client.query(timeline_sql))
timeline_sql = f"""
SELECT ts, infobase, doc_type, operation_type, amount, status, author
FROM analytics_1c.documents
WHERE counterparty = {q(counterparty)}
{"AND infobase = " + q(infobase) if infobase else ""}
ORDER BY ts DESC
LIMIT 20
"""
timeline = rows_to_dict(client.query(timeline_sql))
essence = (
f"Компания {counterparty}: за 30 дней событий {card['docs_30d']}, суммарная активность {card['amount_30d']}, "
f"прогноз активности на 30 дней {card['amount_forecast_30d']}, риск {card['signal_severity']}."
)
return {
"essence": essence,
"card": card,
"company_state": company_state[0] if company_state else None,
"forecasts": forecasts,
"signals": signals,
"recent_documents": timeline,
}
@app.get("/api/1/analytics-1c/companies/{counterparty}/forecast")
def company_forecast(
counterparty: str,
infobase: str | None = None,
horizon_days: int | None = Query(default=None, ge=1, le=365),
) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
if horizon_days is not None:
filters.append(f"horizon_days = {int(horizon_days)}")
sql = f"""
SELECT generated_at, infobase, counterparty, metric, horizon_days, baseline_daily, trend_slope, predicted_daily, predicted_total, confidence, note
FROM analytics_1c.v_company_forecasts_current
WHERE {' AND '.join(filters)}
ORDER BY metric, horizon_days
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
@app.get("/api/1/analytics-1c/companies/{counterparty}/timeline")
def company_timeline(
counterparty: str,
infobase: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
client = ch_client()
filters = [f"counterparty = {q(counterparty)}"]
if infobase:
filters.append(f"infobase = {q(infobase)}")
sql = f"""
SELECT ts, infobase, organization, doc_type, doc_number, author, operation_type, amount, status, posted
FROM analytics_1c.documents
WHERE {' AND '.join(filters)}
ORDER BY ts DESC
LIMIT {int(limit)}
"""
rows = rows_to_dict(client.query(sql))
return {"items": rows, "count": len(rows)}
@app.get("/api/1/analytics-1c/manager/brief/latest")
def manager_brief_latest() -> dict[str, Any]:
return load_latest_manager_brief()
@app.get("/api/1/analytics-1c/manager/brief/latest.md", response_class=PlainTextResponse)
def manager_brief_latest_markdown() -> str:
latest_md = manager_brief_state_dir() / "latest.md"
if not latest_md.exists():
raise HTTPException(status_code=404, detail="manager brief markdown not generated yet")
return latest_md.read_text(encoding="utf-8")
@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]:
items = load_brief_history_records(limit)
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/manager/brief/delta/latest")
def manager_brief_delta_latest() -> dict[str, Any]:
payload = load_latest_manager_brief()
delta = extract_delta(payload)
return {
"generated_at": payload.get("generated_at"),
"headline": payload.get("brief", {}).get("headline"),
"delta": delta,
}
@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)
def manager_brief_view() -> str:
return render_manager_brief_html(load_latest_manager_brief())
@app.get("/manager/changes", response_class=HTMLResponse)
def manager_brief_delta_view() -> str:
return render_brief_delta_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/briefs/{name}/changes", response_class=HTMLResponse)
def manager_brief_history_delta_view(name: str) -> str:
return render_brief_delta_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:
card = summary_payload["card"]
company_state = summary_payload.get("company_state") or {}
forecasts = summary_payload.get("forecasts") or []
signals = summary_payload.get("signals") or []
recent_documents = summary_payload.get("recent_documents") or []
title = card.get("counterparty", "Карточка компании")
subtitle = summary_payload.get("essence", "")
grafana_url = grafana_company_dashboard_url()
summary_url = f"/api/1/analytics-1c/companies/{quote(card['counterparty'])}/summary"
if infobase:
summary_url += f"?infobase={quote(infobase)}"
timeline_url = f"/api/1/analytics-1c/companies/{quote(card['counterparty'])}/timeline"
if infobase:
timeline_url += f"?infobase={quote(infobase)}"
forecast_url = f"/api/1/analytics-1c/companies/{quote(card['counterparty'])}/forecast"
if infobase:
forecast_url += f"?infobase={quote(infobase)}"
if card.get("signal_severity") == "critical":
manager_comment = "Компания требует немедленного внимания: контур считает её operational-critical."
elif card.get("signal_severity") == "high":
manager_comment = "Компания в зоне повышенного внимания: нужен короткий управленческий разбор причин сигнала."
elif card.get("signal_severity") == "medium":
manager_comment = "Компания не аварийная, но требует точечной проверки причин отклонения."
else:
manager_comment = "По компании нет выраженного аварийного сигнала; смотреть контекст и тренд активности."
metrics = [
("Активность 7д", fmt_number(card.get("amount_7d"))),
("Активность 30д", fmt_number(card.get("amount_30d"))),
("Прогноз 30д", fmt_number(card.get("amount_forecast_30d"))),
("Документы 30д", fmt_number(card.get("docs_30d"))),
("Кейсы", fmt_number(card.get("open_cases_total"))),
("Detections", fmt_number(card.get("detections_total"))),
]
metric_cards = "".join(
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 metrics
)
forecast_rows = []
for item in forecasts:
forecast_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('metric', '-')))}</td>"
f"<td>{fmt_number(item.get('horizon_days'))}</td>"
f"<td>{fmt_number(item.get('baseline_daily'))}</td>"
f"<td>{fmt_number(item.get('predicted_total'))}</td>"
f"<td>{fmt_number(item.get('confidence'))}</td>"
f"<td>{html.escape(str(item.get('note', '-')))}</td>"
"</tr>"
)
signal_rows = []
for item in signals[:12]:
signal_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('generated_at', '-')))}</td>"
f"<td>{severity_badge(str(item.get('severity', 'none')))}</td>"
f"<td>{fmt_number(item.get('score'))}</td>"
f"<td>{html.escape(str(item.get('signal_type', '-')))}</td>"
f"<td>{html.escape(str(item.get('summary', '-')))}</td>"
"</tr>"
)
document_rows = []
for item in recent_documents[:20]:
document_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('ts', '-')))}</td>"
f"<td>{html.escape(str(item.get('doc_type', '-')))}</td>"
f"<td>{html.escape(str(item.get('operation_type', '-')))}</td>"
f"<td>{fmt_number(item.get('amount'))}</td>"
f"<td>{html.escape(str(item.get('status', '-')))}</td>"
f"<td>{html.escape(str(item.get('author', '-')))}</td>"
"</tr>"
)
registry_comment = (
f"Сопоставление с реестром: {card.get('registry_match_mode', 'none')}. "
f"Ответственный: {card.get('registry_assignee_name') or 'не указан'}."
)
if card.get("registry_match_mode") == "manual":
registry_comment += " Требуется осторожность: запись заведена через manual override."
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>{html.escape(title)} · 1C Company Brief</title>
<style>
:root {{
--bg: #f4f1ea;
--paper: #fffdf8;
--ink: #1c1a17;
--muted: #6b655c;
--line: #d8d0c4;
--accent: #005f73;
--critical: #9b2226;
--high: #bb3e03;
--medium: #ca6702;
--low: #4d7c0f;
--none: #687076;
--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;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(0, 95, 115, 0.08), transparent 28%),
linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
}}
.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, 42px); 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;
}}
.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; }}
.span-12 {{ grid-column: span 12; }}
.span-8 {{ grid-column: span 8; }}
.span-6 {{ grid-column: span 6; }}
.span-4 {{ grid-column: span 4; }}
.summary-box {{
border: 1px solid var(--line); border-left: 5px solid var(--accent); border-radius: 18px; padding: 16px 18px;
background: #fffdfa; line-height: 1.55;
}}
.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: -0.03em; }}
.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); }}
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; }}
.meta-list {{ margin: 0; padding-left: 18px; line-height: 1.55; }}
@media (max-width: 1100px) {{
.span-8, .span-6, .span-4 {{ 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; }}
th:nth-child(2), td:nth-child(2) {{ display: none; }}
}}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<div class="hero-top">
<div>
<div class="hero-meta">AW-rus · Company Brief · {severity_badge(str(card.get("signal_severity", "none")))}</div>
<h1>{html.escape(title)}</h1>
<div class="hero-meta">{html.escape(subtitle)}</div>
</div>
<nav class="hero-links">
<a href="/manager/brief">К портфелю</a>
<a href="/manager/changes">Что изменилось</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(forecast_url)}">JSON forecast</a>
<a href="{html.escape(timeline_url)}">JSON timeline</a>
<a href="{html.escape(grafana_url)}">Grafana</a>
</nav>
</div>
</section>
<section class="grid">
<article class="panel span-8">
<h2>Управленческий комментарий</h2>
<div class="summary-box">
<p><strong>{html.escape(manager_comment)}</strong></p>
<p>{html.escape(registry_comment)}</p>
<p>Сейчас база в состоянии <strong>{html.escape(str(card.get("current_status", "-")))}</strong>, активных блокировок: <strong>{fmt_number(card.get("active_locks"))}</strong>, дней с последней активности: <strong>{fmt_number(card.get("days_since_last_activity"))}</strong>.</p>
</div>
</article>
<article class="panel span-4">
<h2>Ключевые метрики</h2>
<div class="stats">
{metric_cards}
</div>
</article>
<article class="panel span-6">
<h2>Карточка компании</h2>
<ul class="meta-list">
<li><strong>Компания:</strong> {html.escape(str(card.get("company_name") or card.get("counterparty") or "-"))}</li>
<li><strong>Нормализованное имя:</strong> {html.escape(str(card.get("normalized_counterparty") or "-"))}</li>
<li><strong>Инфобаза:</strong> {html.escape(str(card.get("infobase") or "-"))}</li>
<li><strong>Ответственный:</strong> {html.escape(str(card.get("registry_assignee_name") or card.get("owner_user") or "-"))}</li>
<li><strong>Match mode:</strong> {html.escape(str(card.get("registry_match_mode") or "-"))}</li>
<li><strong>ИНН / КПП:</strong> {html.escape(str(card.get("registry_inn") or "-"))} / {html.escape(str(card.get("registry_kpp") or "-"))}</li>
<li><strong>Путь базы:</strong> {html.escape(str(card.get("base_path") or "-"))}</li>
</ul>
</article>
<article class="panel span-6">
<h2>Состояние файловой базы</h2>
<ul class="meta-list">
<li><strong>Статус:</strong> {html.escape(str(company_state.get("current_status") or card.get("current_status") or "-"))}</li>
<li><strong>Размер базы:</strong> {fmt_number(company_state.get("db_size_bytes") or card.get("db_size_bytes"))}</li>
<li><strong>Размер reglog:</strong> {fmt_number(company_state.get("reglog_size_bytes") or card.get("reglog_size_bytes"))}</li>
<li><strong>Активные блокировки:</strong> {fmt_number(company_state.get("active_locks") or card.get("active_locks"))}</li>
<li><strong>Текущий activity score:</strong> {fmt_number(company_state.get("current_activity_score") or card.get("current_activity_score"))}</li>
<li><strong>Последний snapshot:</strong> {html.escape(str(company_state.get("ts") or card.get("last_company_snapshot_at") or "-"))}</li>
</ul>
</article>
<article class="panel span-12">
<h2>Прогнозы</h2>
<table>
<thead>
<tr>
<th>Метрика</th>
<th>Горизонт, д</th>
<th>Baseline</th>
<th>Прогноз total</th>
<th>Confidence</th>
<th>Примечание</th>
</tr>
</thead>
<tbody>
{''.join(forecast_rows) or '<tr><td colspan="6">Нет данных.</td></tr>'}
</tbody>
</table>
</article>
<article class="panel span-12">
<h2>Сигналы</h2>
<table>
<thead>
<tr>
<th>Время</th>
<th>Severity</th>
<th>Score</th>
<th>Тип</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody>
{''.join(signal_rows) or '<tr><td colspan="5">Нет данных.</td></tr>'}
</tbody>
</table>
</article>
<article class="panel span-12">
<h2>Последние события</h2>
<table>
<thead>
<tr>
<th>Время</th>
<th>Тип</th>
<th>Операция</th>
<th>Активность</th>
<th>Статус</th>
<th>Автор</th>
</tr>
</thead>
<tbody>
{''.join(document_rows) or '<tr><td colspan="6">Нет данных.</td></tr>'}
</tbody>
</table>
</article>
</section>
</main>
</body>
</html>"""
@app.get("/manager/company/{counterparty}", response_class=HTMLResponse)
def manager_company_view(counterparty: str, infobase: str | None = None) -> str:
return render_company_detail_html(company_summary(counterparty, infobase), infobase)
if __name__ == "__main__":
args = parse_args()
uvicorn.run(app, host=args.host, port=args.port)