Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
This commit is contained in:
@@ -2,9 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import market_stocks, llm_router
|
||||
@@ -362,7 +365,57 @@ def _collect_rss_market(data: dict[str, Any]) -> None:
|
||||
data["rss_items"] = _safe_count("rss_items")
|
||||
|
||||
|
||||
def collect_briefing_data() -> dict[str, Any]:
|
||||
def _classify_revenue_brand(name: str) -> str:
|
||||
n = (name or "").lower()
|
||||
if any(k in n for k in ("foodlinkk", "linknbit", "linknbi", "wereldexport")):
|
||||
return "foodlinkk"
|
||||
if "cucina" in n and "foodlinkk" not in n:
|
||||
return "cucina"
|
||||
chains = ("ah", "jumbo", "picnic", "plus", "vomar", "dirk", "hoogvliet", "deka", "spar", "doner")
|
||||
if any(n == c or n.startswith(c + " ") for c in chains):
|
||||
return "cucina"
|
||||
if any(k in n for k in ("foodservice", "snack", "diepvries", "halal", "gas stunning", "marketing budget adds cucina")):
|
||||
return "cucina"
|
||||
if any(k in n for k in ("fresh supplier", "boerschappen", "subsidies cucina")):
|
||||
return "foodlinkk"
|
||||
if any(k in n for k in ("total earnings", "loonkosten", "total expenses", "verkopen cucina")):
|
||||
return "shared"
|
||||
return "other"
|
||||
|
||||
|
||||
def _collect_revenue_cockpit(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
from app.services import revenue_cockpit as rc
|
||||
|
||||
projects = rc.list_projects()
|
||||
stats = rc.dashboard_stats()
|
||||
by_brand: dict[str, list] = {"cucina": [], "foodlinkk": [], "shared": [], "other": []}
|
||||
for p in projects:
|
||||
if (p.get("status") or "") != "active":
|
||||
continue
|
||||
brand = _classify_revenue_brand(p.get("name") or "")
|
||||
by_brand[brand].append(p)
|
||||
data["revenue"] = {
|
||||
"stats": stats,
|
||||
"by_brand": {
|
||||
k: sorted(v, key=lambda x: float(x.get("margin_month") or 0), reverse=True)[:6]
|
||||
for k, v in by_brand.items()
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
data["revenue"] = {}
|
||||
|
||||
|
||||
def _collect_export_intel(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/stats"
|
||||
r = httpx.get(url, timeout=4.0)
|
||||
data["export_intel"] = r.json() if r.status_code == 200 else {}
|
||||
except Exception:
|
||||
data["export_intel"] = {}
|
||||
|
||||
|
||||
def collect_briefing_data(*, skip_halal: bool = False) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"date": date.today().isoformat(),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -374,10 +427,37 @@ def collect_briefing_data() -> dict[str, Any]:
|
||||
_collect_nas_analytics(data)
|
||||
_collect_retail_intel(data)
|
||||
_collect_rss_market(data)
|
||||
_collect_revenue_cockpit(data)
|
||||
_collect_export_intel(data)
|
||||
if not skip_halal:
|
||||
_collect_halal_recommendations(data)
|
||||
data["activity_log"] = _build_activity_log(data)
|
||||
return data
|
||||
|
||||
|
||||
def _collect_halal_recommendations(data: dict[str, Any]) -> None:
|
||||
try:
|
||||
url = f"{settings.TOOLS_API_URL.rstrip('/')}/recommendations/halal/live"
|
||||
r = httpx.get(url, params={"limit": 5, "brand": "cucina"}, timeout=20.0)
|
||||
data["halal_recommendations"] = r.json() if r.status_code == 200 else {"items": []}
|
||||
except Exception:
|
||||
data["halal_recommendations"] = {"items": []}
|
||||
# Legacy veld voor backwards compat — eerste 3 engine results
|
||||
items = (data.get("halal_recommendations") or {}).get("items") or []
|
||||
data["top_opportunities"] = [
|
||||
{
|
||||
"id": i.get("store_id"),
|
||||
"name": (i.get("title") or "").split(" · ", 1)[-1],
|
||||
"chain": i.get("chain"),
|
||||
"city": i.get("city"),
|
||||
"halal_opportunity_score": i.get("halal_opportunity_score") or i.get("score"),
|
||||
"composite_score": i.get("score"),
|
||||
"reasons": i.get("reasons"),
|
||||
}
|
||||
for i in items[:5]
|
||||
]
|
||||
|
||||
|
||||
async def stream_daily_briefing():
|
||||
"""Yield SSE step events while building the CEO daily report."""
|
||||
data: dict[str, Any] = {
|
||||
@@ -585,6 +665,222 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
|
||||
return lines[:25]
|
||||
|
||||
|
||||
def _strip_activity_prefix(line: str) -> str:
|
||||
for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
|
||||
if line.startswith(prefix):
|
||||
return line[len(prefix) :].strip()
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _short_activity(line: str, max_len: int = 88) -> str:
|
||||
s = _strip_activity_prefix(line)
|
||||
s = re.sub(r" — \d{4}-\d{2}-\d{2}$", "", s)
|
||||
s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", "", s).strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
if len(s) > max_len:
|
||||
return s[: max_len - 1].rstrip() + "…"
|
||||
return s
|
||||
|
||||
|
||||
def _fmt_eur(val: float | int | None) -> str:
|
||||
try:
|
||||
return f"€{float(val or 0):,.0f}"
|
||||
except (TypeError, ValueError):
|
||||
return "€—"
|
||||
|
||||
|
||||
def _revenue_line(p: dict[str, Any]) -> str:
|
||||
mm = p.get("margin_month")
|
||||
margin = f" · marge/maand {_fmt_eur(mm)}" if mm else ""
|
||||
obj = p.get("open_objectives") or 0
|
||||
extra = f" · {obj} open stappen" if obj else ""
|
||||
return f"{p.get('name')}{margin}{extra}"
|
||||
|
||||
|
||||
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Gestructureerde live samenvatting — Cucina B2C + Foodlinkk B2B + cockpit."""
|
||||
activity = data.get("activity_log") or []
|
||||
opp = data.get("top_opportunities") or []
|
||||
pending_n = int(data.get("pending_approvals") or 0)
|
||||
revenue = data.get("revenue") or {}
|
||||
rev_stats = revenue.get("stats") or {}
|
||||
rev_goals = rev_stats.get("goals") or {}
|
||||
export = data.get("export_intel") or {}
|
||||
market = data.get("market_summary") or {}
|
||||
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
# —— Foodlinkk B2B ——
|
||||
fl_lines: list[str] = [
|
||||
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
|
||||
]
|
||||
if export:
|
||||
fl_lines.append(
|
||||
f"Wereldexport: {export.get('distributors', 0):,} distributeurs · "
|
||||
f"{export.get('entities', 0):,} entiteiten · {export.get('tenders_open', 0)} open tenders · "
|
||||
f"fase {export.get('phase', '—')}"
|
||||
)
|
||||
if export.get("crm_linked"):
|
||||
fl_lines.append(f"Export → CRM gekoppeld: {export.get('crm_linked')} records")
|
||||
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
|
||||
fl_projects = sorted(
|
||||
fl_projects,
|
||||
key=lambda p: (
|
||||
1 if "verkopen" in (p.get("name") or "").lower() else 0,
|
||||
-float(p.get("margin_month") or 0),
|
||||
),
|
||||
)
|
||||
if fl_projects:
|
||||
fl_lines.append("Revenue projecten: " + "; ".join(_revenue_line(p) for p in fl_projects[:3]))
|
||||
if rev_goals.get("vision_text"):
|
||||
vision = str(rev_goals["vision_text"]).strip().split("\n")[0][:90]
|
||||
fl_lines.append(f"Visie: {vision}")
|
||||
sections.append({"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines})
|
||||
|
||||
# —— Cucina B2C ——
|
||||
cu_lines: list[str] = [
|
||||
f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships",
|
||||
f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items",
|
||||
]
|
||||
if rev_stats:
|
||||
cu_lines.insert(
|
||||
0,
|
||||
f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · "
|
||||
f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
|
||||
f"{rev_stats.get('open_objectives', 0)} open stappen",
|
||||
)
|
||||
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
|
||||
if cu_projects:
|
||||
cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4]))
|
||||
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
|
||||
if halal_items:
|
||||
cu_lines.append("🎯 Halal kansen (live engine):")
|
||||
for rec in halal_items[:4]:
|
||||
reason = (rec.get("reasons") or [""])[0]
|
||||
cu_lines.append(
|
||||
f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')} — "
|
||||
f"score {rec.get('score')} · {reason}"
|
||||
)
|
||||
elif opp:
|
||||
top = opp[0]
|
||||
cu_lines.append(
|
||||
f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — "
|
||||
f"score {round(float(top.get('halal_opportunity_score') or 0))}/100"
|
||||
)
|
||||
ms = data.get("milestones_pending") or []
|
||||
if ms:
|
||||
cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})")
|
||||
sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines})
|
||||
|
||||
# —— Vandaag in cockpit ——
|
||||
cockpit_lines: list[str] = []
|
||||
if pending_n:
|
||||
cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou")
|
||||
for row in (data.get("pending_approval_requests") or [])[:3]:
|
||||
cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}")
|
||||
for line in activity[:5]:
|
||||
cockpit_lines.append(_short_activity(line))
|
||||
if not cockpit_lines:
|
||||
cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.")
|
||||
sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "⚡", "lines": cockpit_lines[:6]})
|
||||
|
||||
# —— Markt & kennis ——
|
||||
news_lines: list[str] = []
|
||||
if market.get("best_performer"):
|
||||
bp = market["best_performer"]
|
||||
news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
|
||||
seen_news: set[str] = set()
|
||||
for row in (data.get("trending_food") or []):
|
||||
title = str(row.get("title") or "").strip()[:95]
|
||||
if not title or title in seen_news:
|
||||
continue
|
||||
seen_news.add(title)
|
||||
news_lines.append(title)
|
||||
if len(seen_news) >= 2:
|
||||
break
|
||||
if data.get("nas_docs"):
|
||||
news_lines.append(
|
||||
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
|
||||
)
|
||||
if news_lines:
|
||||
sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]})
|
||||
|
||||
actions: list[str] = []
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
label = (row.get("title") or row.get("action_type") or "goedkeuring")[:60]
|
||||
actions.append(f"@{row.get('agent_key')}: {label}")
|
||||
for row in (data.get("milestones_pending") or [])[:2]:
|
||||
actions.append(f"Milestone: {row.get('title')}")
|
||||
for row in (revenue.get("by_brand") or {}).get("cucina") or []:
|
||||
if row.get("open_objectives"):
|
||||
actions.append(f"Cucina follow-up: {row.get('name')}")
|
||||
break
|
||||
for rec in halal_items[:2]:
|
||||
actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
|
||||
if export.get("tenders_open"):
|
||||
actions.append(f"Wereldexport: {export.get('tenders_open')} open tenders bekijken")
|
||||
if not actions:
|
||||
actions.extend([
|
||||
"Retail 360: top-3 halal-gap filialen benaderen",
|
||||
"Marketing Hub: kant-en-klaar trends checken",
|
||||
])
|
||||
|
||||
long_term = [
|
||||
"Cucina: schaal supermarkt-listings (AH, Jumbo, PLUS) naar actieve partnerships",
|
||||
"Foodlinkk: distributeurs in Wereldexport koppelen aan CRM-deals",
|
||||
"Revenue Cockpit: open stappen en marges wekelijks reviewen",
|
||||
]
|
||||
if rev_goals.get("horizon_text"):
|
||||
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
|
||||
|
||||
# Platte summary voor backwards compat (notifications)
|
||||
summary = f"{data['date']} · Foodlinkk B2B + Cucina B2C · {len(activity)} activiteiten vandaag"
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"sections": sections,
|
||||
"actions": actions[:6],
|
||||
"long_term": long_term[:5],
|
||||
"updated_at": data.get("generated_at"),
|
||||
"activity_count": len(activity),
|
||||
"source": "live",
|
||||
}
|
||||
|
||||
|
||||
def format_live_digest_text(digest: dict[str, Any]) -> str:
|
||||
"""Platte tekst voor Telegram / Herman chat — halal zit in Cucina-sectie."""
|
||||
lines = ["📋 Herman briefing · live", "", digest.get("summary") or ""]
|
||||
for section in digest.get("sections") or []:
|
||||
icon = section.get("icon") or ""
|
||||
title = section.get("title") or ""
|
||||
lines.append("")
|
||||
lines.append(f"{icon} {title}".strip())
|
||||
for line in section.get("lines") or []:
|
||||
lines.append(f" · {line}")
|
||||
actions = digest.get("actions") or []
|
||||
if actions:
|
||||
lines.extend(["", "⚡ Korte termijn"])
|
||||
for action in actions[:6]:
|
||||
lines.append(f" · {action}")
|
||||
long_term = digest.get("long_term") or []
|
||||
if long_term:
|
||||
lines.extend(["", "🎯 Lange termijn"])
|
||||
for item in long_term[:5]:
|
||||
lines.append(f" · {item}")
|
||||
updated = digest.get("updated_at")
|
||||
if updated:
|
||||
ts = str(updated)[:19].replace("T", " ")
|
||||
n = digest.get("activity_count")
|
||||
extra = f" · {n} activiteiten" if n is not None else ""
|
||||
lines.extend(["", f"Bijgewerkt {ts} UTC{extra}"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_live_digest_text(data: dict[str, Any] | None = None) -> str:
|
||||
payload = data if data is not None else collect_briefing_data()
|
||||
return format_live_digest_text(build_live_digest(payload))
|
||||
|
||||
|
||||
def build_template_report(data: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"# Foodlinkk Dagrapport — {data['date']}",
|
||||
@@ -715,39 +1011,19 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _fallback_summary(data: dict[str, Any]) -> str:
|
||||
opp = data.get("top_opportunities") or []
|
||||
ms = data.get("milestones_pending") or []
|
||||
lines = [
|
||||
"## Samenvatting",
|
||||
f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
|
||||
f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
|
||||
]
|
||||
if opp:
|
||||
top = opp[0]
|
||||
lines.append(
|
||||
f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
|
||||
f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
|
||||
)
|
||||
digest = build_live_digest(data)
|
||||
lines = ["## Samenvatting", digest["summary"]]
|
||||
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
|
||||
actions = [
|
||||
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
|
||||
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
|
||||
"Check Marketing Live Feed voor kant-en-klaar trends",
|
||||
]
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
if row.get("action_type") == "maintenance_scan":
|
||||
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
|
||||
break
|
||||
if ms:
|
||||
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
|
||||
for a in actions[:6]:
|
||||
for a in digest["actions"]:
|
||||
lines.append(f"- {a}")
|
||||
lines.extend(["", "## Lange termijn focus"])
|
||||
lines.extend([
|
||||
"- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
|
||||
"- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
|
||||
"- Wekelijks milestones review in Retail 360 sales tab",
|
||||
])
|
||||
for a in digest["long_term"]:
|
||||
lines.append(f"- {a}")
|
||||
activity = data.get("activity_log") or []
|
||||
if activity:
|
||||
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
|
||||
for entry in activity[:8]:
|
||||
lines.append(f"- {_strip_activity_prefix(entry)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -801,4 +1077,6 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
|
||||
content = _fallback_summary(data) + "\n\n---\n\n" + template
|
||||
|
||||
_save_briefing(content, data)
|
||||
return content, serialize_stats(data)
|
||||
stats = serialize_stats(data)
|
||||
stats["live_digest"] = build_live_digest(data)
|
||||
return content, stats
|
||||
|
||||
Reference in New Issue
Block a user