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
|
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]:
|
def load_brief_history_record(name: str) -> dict[str, Any]:
|
||||||
safe_name = Path(name).name
|
safe_name = Path(name).name
|
||||||
if not safe_name.endswith(".json"):
|
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"}
|
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:
|
def grafana_company_dashboard_url() -> str:
|
||||||
return os.getenv(
|
return os.getenv(
|
||||||
"AW_1C_MANAGER_BRIEF_GRAFANA_URL",
|
"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_url = "/api/1/analytics-1c/manager/brief/history"
|
||||||
history_html_url = "/manager/briefs"
|
history_html_url = "/manager/briefs"
|
||||||
delta_html_url = "/manager/changes"
|
delta_html_url = "/manager/changes"
|
||||||
|
weekly_html_url = "/manager/trends/weekly"
|
||||||
problematic_1d_url = "/manager/problematic?days=1"
|
problematic_1d_url = "/manager/problematic?days=1"
|
||||||
problematic_7d_url = "/manager/problematic?days=7"
|
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"
|
||||||
@@ -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(md_url)}">Markdown</a>
|
||||||
<a href="{html.escape(history_html_url)}">История brief</a>
|
<a href="{html.escape(history_html_url)}">История brief</a>
|
||||||
<a href="{html.escape(delta_html_url)}">Что изменилось</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_1d_url)}">Проблемные 1д</a>
|
||||||
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a>
|
<a href="{html.escape(problematic_7d_url)}">Проблемные 7д</a>
|
||||||
<a href="{html.escape(history_url)}">History API</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">
|
<div class="hero-links">
|
||||||
<a href="/manager/brief">Текущий brief</a>
|
<a href="/manager/brief">Текущий brief</a>
|
||||||
<a href="/manager/changes">Что изменилось</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=1">Проблемные 1д</a>
|
||||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -739,6 +858,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) ->
|
|||||||
<div class="hero-links">
|
<div class="hero-links">
|
||||||
<a href="/manager/brief">Текущий brief</a>
|
<a href="/manager/brief">Текущий brief</a>
|
||||||
<a href="/manager/briefs">История 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=1">Срез 1д</a>
|
||||||
<a href="/manager/problematic?days=7">Срез 7д</a>
|
<a href="/manager/problematic?days=7">Срез 7д</a>
|
||||||
</div>
|
</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>"
|
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
|
for label, value in stat_cards
|
||||||
)
|
)
|
||||||
|
tier_labels = {
|
||||||
|
"critical": "Критический",
|
||||||
|
"high": "Высокий",
|
||||||
|
"medium": "Средний",
|
||||||
|
"low": "Низкий",
|
||||||
|
}
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for item in top_changes:
|
for item in top_changes:
|
||||||
infobase = item.get("infobase")
|
infobase = item.get("infobase")
|
||||||
counterparty = item.get("company")
|
counterparty = item.get("company")
|
||||||
|
priority_tier = str(item.get("priority_tier") or "low")
|
||||||
rows.append(
|
rows.append(
|
||||||
"<tr>"
|
"<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><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('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>{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('score_delta', 0))}</td>"
|
||||||
f"<td>{delta_value(item.get('open_cases_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('active_locks_delta', 0))}</td>"
|
||||||
f"<td>{delta_value(item.get('forecast_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>"
|
f"<td>{html.escape(str(item.get('summary') or '-'))}</td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
@@ -873,6 +1003,7 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
|||||||
<div class="hero-links">
|
<div class="hero-links">
|
||||||
<a href="/manager/brief">Текущий brief</a>
|
<a href="/manager/brief">Текущий brief</a>
|
||||||
<a href="/manager/briefs">История 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=1">Проблемные 1д</a>
|
||||||
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
<a href="/manager/problematic?days=7">Проблемные 7д</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,17 +1041,20 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Компания</th>
|
<th>Компания</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
|
<th>Score</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Score Δ</th>
|
<th>Score Δ</th>
|
||||||
<th>Cases Δ</th>
|
<th>Cases Δ</th>
|
||||||
<th>Locks Δ</th>
|
<th>Locks Δ</th>
|
||||||
<th>Forecast Δ</th>
|
<th>Forecast Δ</th>
|
||||||
|
<th>Причина приоритета</th>
|
||||||
<th>Комментарий</th>
|
<th>Комментарий</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{''.join(rows) or '<tr><td colspan="8">Нет выраженных изменений.</td></tr>'}
|
{''.join(rows) or '<tr><td colspan="11">Нет выраженных изменений.</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</article>
|
||||||
@@ -930,6 +1064,155 @@ def render_brief_delta_html(payload: dict[str, Any]) -> str:
|
|||||||
</html>"""
|
</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")
|
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")
|
@app.get("/api/1/analytics-1c/companies/problematic")
|
||||||
def problematic_companies_api(
|
def problematic_companies_api(
|
||||||
days: int = Query(default=7, ge=1, le=30),
|
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())
|
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)
|
@app.get("/manager/briefs", response_class=HTMLResponse)
|
||||||
def manager_brief_history_view(limit: int = Query(default=40, ge=1, le=200)) -> str:
|
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))
|
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">
|
<nav class="hero-links">
|
||||||
<a href="/manager/brief">К портфелю</a>
|
<a href="/manager/brief">К портфелю</a>
|
||||||
<a href="/manager/changes">Что изменилось</a>
|
<a href="/manager/changes">Что изменилось</a>
|
||||||
|
<a href="/manager/trends/weekly">Неделя</a>
|
||||||
<a href="/manager/briefs">История brief</a>
|
<a href="/manager/briefs">История brief</a>
|
||||||
<a href="/manager/problematic?days=7">Проблемные компании</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>
|
||||||
|
|||||||
@@ -117,6 +117,60 @@ def severity_rank(value: str | None) -> int:
|
|||||||
}.get((value or "none").lower(), 0)
|
}.get((value or "none").lower(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def rank_top_change(change: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
severity_after = str(change.get("severity_after") or "none")
|
||||||
|
severity_before = str(change.get("severity_before") or "none")
|
||||||
|
severity_delta = max(severity_rank(severity_after) - severity_rank(severity_before), 0)
|
||||||
|
cases_delta = max(int(change.get("open_cases_delta") or 0), 0)
|
||||||
|
detections_delta = max(int(change.get("detections_delta") or 0), 0)
|
||||||
|
locks_delta = max(int(change.get("active_locks_delta") or 0), 0)
|
||||||
|
score_delta = max(int(change.get("score_delta") or 0), 0)
|
||||||
|
forecast_before = float(change.get("forecast_before") or 0)
|
||||||
|
forecast_delta = float(change.get("forecast_delta") or 0)
|
||||||
|
forecast_drop_pct = 0.0
|
||||||
|
if forecast_delta < 0:
|
||||||
|
forecast_drop_pct = abs(forecast_delta) / max(abs(forecast_before), 1.0) * 100.0
|
||||||
|
|
||||||
|
priority_score = round(
|
||||||
|
float(change.get("significance") or 0)
|
||||||
|
+ severity_delta * 24
|
||||||
|
+ cases_delta * 8
|
||||||
|
+ detections_delta * 5
|
||||||
|
+ locks_delta * 10
|
||||||
|
+ score_delta * 0.8
|
||||||
|
+ forecast_drop_pct * 0.6,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if severity_delta > 0:
|
||||||
|
reasons.append("рост severity")
|
||||||
|
if cases_delta > 0:
|
||||||
|
reasons.append(f"рост кейсов +{cases_delta}")
|
||||||
|
if detections_delta > 0:
|
||||||
|
reasons.append(f"рост detections +{detections_delta}")
|
||||||
|
if locks_delta > 0:
|
||||||
|
reasons.append(f"рост блокировок +{locks_delta}")
|
||||||
|
if forecast_drop_pct >= 10:
|
||||||
|
reasons.append(f"просадка прогноза {round(forecast_drop_pct, 1)}%")
|
||||||
|
if change.get("registry_match_mode") == "manual":
|
||||||
|
reasons.append("manual match")
|
||||||
|
|
||||||
|
if priority_score >= 140:
|
||||||
|
priority_tier = "critical"
|
||||||
|
elif priority_score >= 85:
|
||||||
|
priority_tier = "high"
|
||||||
|
elif priority_score >= 40:
|
||||||
|
priority_tier = "medium"
|
||||||
|
else:
|
||||||
|
priority_tier = "low"
|
||||||
|
|
||||||
|
change["priority_score"] = priority_score
|
||||||
|
change["priority_tier"] = priority_tier
|
||||||
|
change["priority_reason"] = ", ".join(reasons[:4]) if reasons else "слабый сдвиг без явного триггера"
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
def load_previous_artifact(state_dir: Path) -> dict[str, Any] | None:
|
def load_previous_artifact(state_dir: Path) -> dict[str, Any] | None:
|
||||||
latest_path = state_dir / "latest.json"
|
latest_path = state_dir / "latest.json"
|
||||||
if not latest_path.exists():
|
if not latest_path.exists():
|
||||||
@@ -473,7 +527,8 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
top_changes.append(
|
top_changes.append(
|
||||||
{
|
rank_top_change(
|
||||||
|
{
|
||||||
"infobase": current_item.get("infobase"),
|
"infobase": current_item.get("infobase"),
|
||||||
"company": current_item.get("counterparty"),
|
"company": current_item.get("counterparty"),
|
||||||
"normalized_counterparty": current_item.get("normalized_counterparty"),
|
"normalized_counterparty": current_item.get("normalized_counterparty"),
|
||||||
@@ -498,7 +553,8 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
|
|||||||
"forecast_after": round(forecast_after, 2),
|
"forecast_after": round(forecast_after, 2),
|
||||||
"forecast_delta": forecast_delta,
|
"forecast_delta": forecast_delta,
|
||||||
"significance": round(significance, 2),
|
"significance": round(significance, 2),
|
||||||
}
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
entered_watchlist = sorted(
|
entered_watchlist = sorted(
|
||||||
@@ -510,6 +566,7 @@ def compute_delta_context(current: dict[str, Any], previous_artifact: dict[str,
|
|||||||
|
|
||||||
top_changes.sort(
|
top_changes.sort(
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
|
float(item.get("priority_score") or 0),
|
||||||
float(item.get("significance") or 0),
|
float(item.get("significance") or 0),
|
||||||
int(item.get("score_after") or 0),
|
int(item.get("score_after") or 0),
|
||||||
int(item.get("open_cases_after") or 0),
|
int(item.get("open_cases_after") or 0),
|
||||||
|
|||||||
@@ -148,6 +148,63 @@ class CompanyIntelligenceApiTests(unittest.TestCase):
|
|||||||
self.assertIn("Ключевые изменения", html_page)
|
self.assertIn("Ключевые изменения", html_page)
|
||||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
|
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
|
||||||
|
|
||||||
|
def test_build_weekly_trend_report_and_render(self) -> None:
|
||||||
|
payloads = [
|
||||||
|
{
|
||||||
|
"generated_at": "2026-05-21T12:00:00+00:00",
|
||||||
|
"context": {
|
||||||
|
"portfolio_summary": {
|
||||||
|
"companies_total": 41,
|
||||||
|
"critical_total": 39,
|
||||||
|
"high_total": 2,
|
||||||
|
"busy_total": 40,
|
||||||
|
"open_cases_total": 100,
|
||||||
|
"detections_total": 100,
|
||||||
|
"activity_30d_total": 1000.0,
|
||||||
|
"activity_forecast_30d_total": 2000.0,
|
||||||
|
},
|
||||||
|
"delta": {"top_changes": []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"generated_at": "2026-05-22T12:00:00+00:00",
|
||||||
|
"context": {
|
||||||
|
"portfolio_summary": {
|
||||||
|
"companies_total": 41,
|
||||||
|
"critical_total": 41,
|
||||||
|
"high_total": 0,
|
||||||
|
"busy_total": 41,
|
||||||
|
"open_cases_total": 135,
|
||||||
|
"detections_total": 130,
|
||||||
|
"activity_30d_total": 1200.0,
|
||||||
|
"activity_forecast_30d_total": 2500.0,
|
||||||
|
},
|
||||||
|
"delta": {
|
||||||
|
"top_changes": [
|
||||||
|
{
|
||||||
|
"infobase": "ФЕЛИЦТ ГРУПП 2026",
|
||||||
|
"company": "ФЕЛИЦТ ГРУПП 2026",
|
||||||
|
"change_type": "cases_up",
|
||||||
|
"open_cases_delta": 5,
|
||||||
|
"active_locks_delta": 2,
|
||||||
|
"forecast_delta": -20.0,
|
||||||
|
"priority_score": 180,
|
||||||
|
"priority_tier": "critical",
|
||||||
|
"priority_reason": "рост кейсов +5, рост блокировок +2",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
report = api.build_weekly_trend_report(payloads, days=7)
|
||||||
|
self.assertEqual(len(report["daily"]), 2)
|
||||||
|
self.assertEqual(report["top_weekly_changes"][0]["company"], "ФЕЛИЦТ ГРУПП 2026")
|
||||||
|
html_page = api.render_weekly_trend_html(report)
|
||||||
|
self.assertIn("Недельный тренд портфеля", html_page)
|
||||||
|
self.assertIn("Недельный рейтинг приоритетов", html_page)
|
||||||
|
self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ class ManagerBriefTests(unittest.TestCase):
|
|||||||
self.assertEqual(delta["summary"]["critical_total_delta"], 2)
|
self.assertEqual(delta["summary"]["critical_total_delta"], 2)
|
||||||
self.assertIn("ФЕЛИЦТ ГРУПП 2026", delta["new_critical"])
|
self.assertIn("ФЕЛИЦТ ГРУПП 2026", delta["new_critical"])
|
||||||
self.assertEqual(delta["top_changes"][0]["change_type"], "severity_up")
|
self.assertEqual(delta["top_changes"][0]["change_type"], "severity_up")
|
||||||
|
self.assertIn("priority_score", delta["top_changes"][0])
|
||||||
|
self.assertIn("priority_tier", delta["top_changes"][0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user