6c223c7c9b
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m43s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P1 — Exec-Summary oben im Email-Report (4 KPIs + 2 CTAs, dunkler Gradient)
P3 — no_direct_sales-Flag fuer OEM-Konfigurator-Sites; AGB/Widerruf/AGB als
"NICHT ANWENDBAR" (grau) statt "NICHT GEFUNDEN" (rot)
P5 — Voll-Audit Unification: alle Findings (MC + Pflichtangaben + Vendor +
Redundanz) in /data/compliance_audits.db.unified_findings; neuer
/api/compliance/agent/findings/<id> Endpoint + FindingsTab im Audit-UI
mit Filter + CSV-Export
P7 — Crawl-Hardening: TDM-Reservation-Check (robots.txt / ai.txt / Header /
Meta) vor jedem Run mit 24h-Cache; HeadlessChrome-UA (Firma noch nicht
gegruendet — Switch via BREAKPILOT_BRANDED_UA env); per-Domain
Rate-Limit 1 req/s + max 2 concurrent
P2 — Cookie-Knowledge-DB additiv erweitert (35 -> 74 Cookies): Adobe, Meta,
Microsoft, LinkedIn, TikTok, HubSpot, Marketo, Salesforce, Hotjar,
FullStory, Mouseflow, Intercom, Drift, Zendesk, Cloudflare, Stripe,
OneTrust/Cookiebot/Usercentrics, Matomo, Pinterest, Snapchat, X/Twitter,
YouTube, Vimeo, Klaviyo, Mailchimp, Mixpanel, Segment, Amplitude,
Optimizely, Datadog; Wire-in in cookie_function_classifier liefert
compliance_risk-Label (kritisch/hoch/mittel/gering) pro Vendor
A — k-Anonymitaets-Helper (benchmark_k_anonymity) fuer P6-Vorbereitung
B — Cross-Tenant-Domain-Assertion im /findings-Endpoint (expected_domain
Query-Param -> 403 bei Mismatch)
C — Saving-Scan-Funnel: /api/compliance/agent/saving-scan/start mit
Validierung + 24h-Rate-Limit pro Domain + Lead-Persistenz in
saving_scan_leads + Auto-Discovery via _run_compliance_check; 6 Tests
D — Risk-Badge im Email-Vendor-Row
Rechtliche Leitplanken (Memory feedback_oem_data_legal.md): nur eigene
Knapp-Bewertungen + Source-Pointer, keine 1:1-Kopien fremder CMP-Texte.
TDM-Opt-Out-Respect nach § 44b UrhG. KEINE Schema-Aenderungen — alles in
Sidecar-SQLite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
6.7 KiB
Python
143 lines
6.7 KiB
Python
"""
|
||
Email-Renderer fuer den Vendor-Redundanz + EU-Alternativen + Cost-/Savings-Block.
|
||
|
||
Wird im Email-Body unter dem VVT eingebaut.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
|
||
def _fmt_eur(low: int, high: int) -> str:
|
||
if not low and not high:
|
||
return "im Listpreis bundled"
|
||
if low == high:
|
||
return f"~{low:,} €".replace(",", ".")
|
||
return f"{low:,}–{high:,} €".replace(",", ".")
|
||
|
||
|
||
def build_redundancy_html(report: dict | None) -> str:
|
||
if not report:
|
||
return ""
|
||
s = report.get("summary") or {}
|
||
redundancies = report.get("redundancies") or []
|
||
eu_alts = report.get("eu_alternatives") or []
|
||
multi = report.get("multi_function_tools") or []
|
||
|
||
cur = s.get("estimated_current_year_eur") or [0, 0]
|
||
sav = s.get("estimated_saving_year_eur") or [0, 0]
|
||
pct = s.get("estimated_saving_pct") or "n/a"
|
||
|
||
parts = [
|
||
'<div id="optimierungspotenzial" style="font-family:-apple-system,'
|
||
'BlinkMacSystemFont,sans-serif;max-width:700px;margin:0 auto 16px;'
|
||
'padding:14px 18px;background:#fef3c7;border:1px solid #fcd34d;'
|
||
'border-radius:8px">',
|
||
'<h3 style="margin:0 0 6px;font-size:14px;color:#92400e">'
|
||
'Optimierungspotenzial: Redundanzen + EU-Alternativen</h3>',
|
||
f'<p style="margin:0 0 10px;font-size:11px;color:#78350f">'
|
||
f'<strong>{s.get("redundancy_count", 0)}</strong> Kategorien mit '
|
||
f'mehreren Anbietern · <strong>{s.get("consolidation_potential", 0)}</strong> '
|
||
f'Anbieter konsolidierbar · '
|
||
f'<strong>{s.get("eu_alternative_count", 0)}</strong> EU-Alternativen verfuegbar</p>',
|
||
|
||
'<div style="background:#fff;border:1px solid #fcd34d;border-radius:6px;'
|
||
'padding:10px 12px;margin-bottom:10px">',
|
||
|
||
'<div style="font-size:10px;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">'
|
||
'Diese Schaetzung umfasst NUR die als redundant erkannten Tools — '
|
||
'nicht den Gesamt-Stack der Website</div>',
|
||
|
||
f'<div style="font-size:11px;color:#78350f">'
|
||
f'Listpreis-Schaetzung der <strong>redundanten</strong> Tools '
|
||
f'(Mehrfach-Anbieter in derselben Funktions-Kategorie):'
|
||
f' <strong>{_fmt_eur(*cur)}/Jahr</strong></div>',
|
||
|
||
f'<div style="font-size:11px;color:#16a34a;margin-top:4px">'
|
||
f'Sparpotenzial durch Konsolidierung auf je 1 EU-Tool pro Kategorie:'
|
||
f' <strong>{_fmt_eur(*sav)}/Jahr</strong> ({pct})</div>',
|
||
|
||
'<div style="font-size:10px;color:#94a3b8;margin-top:8px;font-style:italic">'
|
||
'<strong>Wichtige Einschraenkungen:</strong><br/>'
|
||
'• Konzern-Konditionen liegen ueblicherweise 30–50% unter Listpreis — '
|
||
'realistisches Saving entsprechend €X·0,5 bis €X·0,7.<br/>'
|
||
'• Eintraege "<em>Eigene Marke — Tool</em>" (z.B. "BMW AG — Adobe Analytics") '
|
||
'gehoeren oft zu einem einzigen Master-Vertrag, nicht zu mehreren Lizenzen.<br/>'
|
||
'• Media-Spend (Google Ads, Meta Ads) ist NICHT enthalten — nur Tooling-Lizenzen.<br/>'
|
||
'• Quelle: Gartner/Forrester 2025 + oeffentliche Listpreise.'
|
||
'</div></div>',
|
||
]
|
||
|
||
if redundancies:
|
||
parts.append(
|
||
'<table style="width:100%;border-collapse:collapse;font-size:11px;'
|
||
'margin-bottom:10px">'
|
||
'<thead><tr style="background:#fde68a;color:#78350f;text-align:left">'
|
||
'<th style="padding:6px 8px">Kategorie</th>'
|
||
'<th style="padding:6px 8px">#</th>'
|
||
'<th style="padding:6px 8px">Anbieter</th>'
|
||
'<th style="padding:6px 8px">EU-Empfehlung</th>'
|
||
'<th style="padding:6px 8px;text-align:right">Saving / Jahr</th>'
|
||
'</tr></thead><tbody>'
|
||
)
|
||
for r in redundancies[:12]:
|
||
vendors_str = ", ".join(r.get("vendors", [])[:6])
|
||
if len(r.get("vendors", [])) > 6:
|
||
vendors_str += f" (+{len(r['vendors']) - 6} weitere)"
|
||
sav_r = r.get("estimated_saving_year_eur") or [0, 0]
|
||
parts.append(
|
||
f'<tr style="border-top:1px solid #fde68a;vertical-align:top">'
|
||
f'<td style="padding:5px 8px;color:#78350f;font-weight:600">{r["category_label"]}</td>'
|
||
f'<td style="padding:5px 8px;text-align:center">{r["count"]}</td>'
|
||
f'<td style="padding:5px 8px;color:#1e293b;font-size:10px">{vendors_str}</td>'
|
||
f'<td style="padding:5px 8px;color:#16a34a;font-size:10px">{r.get("suggested_eu_tool") or "–"}</td>'
|
||
f'<td style="padding:5px 8px;text-align:right;color:#16a34a;font-weight:600">'
|
||
f'{_fmt_eur(*sav_r)}</td></tr>'
|
||
)
|
||
hint = r.get("consolidation_hint")
|
||
if hint:
|
||
parts.append(
|
||
f'<tr><td colspan="5" style="padding:0 8px 8px;color:#94a3b8;font-size:10px;font-style:italic">'
|
||
f'Hinweis: {hint}</td></tr>'
|
||
)
|
||
caveats = r.get("caveats") or []
|
||
if caveats:
|
||
parts.append(
|
||
f'<tr><td colspan="5" style="padding:0 8px 8px;color:#94a3b8;font-size:10px">'
|
||
f'<strong>Moegliche Gruende fuer Mehrfach-Einsatz:</strong> '
|
||
+ "; ".join(caveats) + '</td></tr>'
|
||
)
|
||
parts.append('</tbody></table>')
|
||
|
||
if multi:
|
||
parts.append(
|
||
'<div style="margin-top:8px"><strong style="font-size:11px;color:#78350f">'
|
||
'Multi-Funktions-Tools (1 Tool ersetzt mehrere Kategorien):</strong>'
|
||
'<ul style="margin:6px 0 0 18px;padding:0;font-size:11px;color:#78350f">'
|
||
)
|
||
for t in multi[:4]:
|
||
cats = ", ".join(t.get("replaces_categories", []))
|
||
parts.append(
|
||
f'<li style="margin-bottom:3px"><strong>{t["name"]}</strong>'
|
||
f' ({t["country"]}) — ersetzt <em>{cats}</em>'
|
||
f' ({t.get("potential_replacements", 0)} Anbieter heute)</li>'
|
||
)
|
||
parts.append('</ul></div>')
|
||
|
||
if eu_alts:
|
||
parts.append(
|
||
'<details style="margin-top:8px"><summary style="font-size:11px;color:#78350f;'
|
||
'cursor:pointer">EU-Alternativen pro Anbieter (Details)</summary>'
|
||
'<ul style="margin:6px 0 0 18px;padding:0;font-size:10px;color:#475569">'
|
||
)
|
||
for e in eu_alts[:20]:
|
||
first_alt = (e.get("alternatives") or [{}])[0]
|
||
parts.append(
|
||
f'<li style="margin-bottom:3px"><strong>{e["current_vendor"]}</strong>'
|
||
f' → {first_alt.get("name", "")} ({first_alt.get("country", "")})'
|
||
f' <span style="color:#94a3b8">— {first_alt.get("notes", "")}</span></li>'
|
||
)
|
||
parts.append('</ul></details>')
|
||
|
||
parts.append('</div>')
|
||
return "".join(parts)
|