- assets/header.svg: animated SVG header (twinkling stars, orbiting service dots, Atlas A logo with glow, float/pulse animations, corner accents) - README.md: animated banner, full 39-app service catalog, Mermaid architecture, AI stack table, tech stack, quick-deploy, repo structure, security model - ARCHITECTURE.md: one-box philosophy, 5-tier service layers, data flow diagram, Caddy SSO pattern, AI mesh topology, Docker Compose topology - SERVICES.md: all 55 containers + 20 systemd services documented - CHANGELOG.md: full build history from v0.5 to v2.0 - assets/logo.svg: Atlas A logo with gradient - deploy/install.sh: one-command installer (Docker + Caddy + Tailscale + stacks) - deploy/compose/atlas-core.yml: Authentik + Nextcloud + Stalwart + Forgejo + Vaultwarden - deploy/caddy/Caddyfile.template: Authentik SSO pattern, sanitized - deploy/README.md: full deployment guide - server/: sanitized atlas_brain.py, cockpit_shim.py, intelligence.py, meridian.py, atlas_router_api.py, cli-atlas.py (all secrets stripped, env var patterns) - server/systemd/: 6 atlas-* service unit files - server/README.md: deployment and configuration guide Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Atlas Intelligence Engine
|
||
Runs on atlas-01. Pulls Odoo invoices, runs Monte Carlo H2 forecast,
|
||
outputs /opt/atlas/setup/intelligence.json (served at /feeds/intelligence.json).
|
||
Schedule: cron every hour or on-demand.
|
||
"""
|
||
|
||
import json, os, random, sys, xmlrpc.client
|
||
from datetime import datetime, timezone
|
||
from urllib.request import urlopen, Request
|
||
from urllib.error import URLError
|
||
|
||
ODOO_URL = 'http://<TAILSCALE_IP>:8069'
|
||
ODOO_DB = 'atlas'
|
||
ODOO_USER = 'chaib@atlascorporation.nl'
|
||
ODOO_PASS = os.environ['ODOO_PASS']
|
||
OUT_PATH = '/opt/atlas/setup/intelligence.json'
|
||
|
||
# ── Verified 2025 actuals (from Odoo export + bank reconciliation) ────────────
|
||
ACTUALS_2025 = {
|
||
'2025-01': 237, '2025-02': 225, '2025-03': 0, '2025-04': 255,
|
||
'2025-05': 1625,'2025-06': 533, '2025-07': 660, '2025-08': -200,
|
||
'2025-09': 1300,'2025-10': 1400,'2025-11': 280, '2025-12': 200,
|
||
}
|
||
ACTUALS_2026_FALLBACK = {
|
||
'2026-01': 68, '2026-02': 977, '2026-03': 4206,
|
||
'2026-04': 1479,'2026-05': 1627,'2026-06': 371,
|
||
}
|
||
|
||
def fetch_odoo():
|
||
"""Pull posted invoices from Odoo via XMLRPC, return {YYYY-MM: amount} dicts."""
|
||
try:
|
||
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
|
||
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
|
||
if not uid:
|
||
return None, None
|
||
|
||
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
|
||
moves = models.execute_kw(ODOO_DB, uid, ODOO_PASS,
|
||
'account.move', 'search_read',
|
||
[[['move_type','in',['out_invoice','out_refund']],
|
||
['state','=','posted'],
|
||
['invoice_date','>=','2025-01-01']]],
|
||
{'fields':['invoice_date','amount_untaxed','move_type'],
|
||
'limit':500}
|
||
)
|
||
if not moves:
|
||
return None, None
|
||
|
||
by_month_2025, by_month_2026 = {}, {}
|
||
for m in moves:
|
||
if not m.get('invoice_date'):
|
||
continue
|
||
ym = m['invoice_date'][:7]
|
||
amt = m['amount_untaxed'] * (-1 if m['move_type'] == 'out_refund' else 1)
|
||
if ym.startswith('2025'):
|
||
by_month_2025[ym] = by_month_2025.get(ym, 0) + amt
|
||
elif ym.startswith('2026'):
|
||
by_month_2026[ym] = by_month_2026.get(ym, 0) + amt
|
||
|
||
return (
|
||
{k: round(v) for k,v in by_month_2025.items()} or None,
|
||
{k: round(v) for k,v in by_month_2026.items()} or None,
|
||
)
|
||
except Exception as e:
|
||
print(f'[intelligence] Odoo pull failed: {e}', file=sys.stderr)
|
||
return None, None
|
||
|
||
def monte_carlo(actuals_2026, n=10000):
|
||
"""Project H2 2026 (Jul–Dec) from recent velocity + POS MRR growth."""
|
||
# Base: last 3 completed months (exclude partial current month)
|
||
months_sorted = sorted(k for k in actuals_2026 if actuals_2026[k] > 0)
|
||
recent = [actuals_2026[k] for k in months_sorted[-3:]] if months_sorted else [1500]
|
||
base_mean = sum(recent) / len(recent)
|
||
base_std = max(base_mean * 0.35, 300)
|
||
|
||
# AtlasPOS: 1 active venue now, conservative 1 new venue/month
|
||
pos_base = 79
|
||
pos_growth_per_mo = 79 # 1 new venue/month
|
||
|
||
results = []
|
||
for _ in range(n):
|
||
total = 0
|
||
for mo in range(6): # Jul through Dec
|
||
consulting = max(0, random.gauss(base_mean, base_std))
|
||
new_venues = max(0, int(random.gauss(1, 0.5)))
|
||
pos_mrr = pos_base + pos_growth_per_mo * mo + new_venues * 79
|
||
total += consulting + pos_mrr
|
||
results.append(total)
|
||
|
||
results.sort()
|
||
full_yr_actuals = sum(actuals_2026.values())
|
||
targets = {
|
||
'beat_2025': sum(ACTUALS_2025.values()),
|
||
'break_even_monthly': 3000,
|
||
}
|
||
|
||
return {
|
||
'p10': round(results[int(n * .10)]),
|
||
'p50': round(results[int(n * .50)]),
|
||
'p90': round(results[int(n * .90)]),
|
||
'full_year_p50': round(full_yr_actuals + results[int(n * .50)]),
|
||
'prob_beat_full_2025_pct': round(
|
||
sum(1 for r in results if full_yr_actuals + r > targets['beat_2025']) / n * 100
|
||
),
|
||
'months': ['Jul','Aug','Sep','Oct','Nov','Dec'],
|
||
}
|
||
|
||
def ltv(arpu=79, margin=0.90, churn=0.04):
|
||
return {
|
||
'arpu': arpu, 'margin_pct': int(margin*100),
|
||
'churn_assumption_pct': int(churn*100),
|
||
'ltv': round(arpu * margin / churn),
|
||
'lifetime_months': round(1/churn),
|
||
'cac_payback_3x_rule': round(arpu * margin / churn / 3),
|
||
}
|
||
|
||
def main():
|
||
a2025, a2026 = fetch_odoo()
|
||
if not a2025: a2025 = ACTUALS_2025
|
||
if not a2026: a2026 = ACTUALS_2026_FALLBACK
|
||
source = 'odoo' if (a2026 is not ACTUALS_2026_FALLBACK or a2025 is not ACTUALS_2025) else 'hardcoded'
|
||
|
||
ytd26 = sum(a2026.values())
|
||
same25 = sum(a2025.get(k.replace('2026','2025'), 0) for k in a2026)
|
||
yoy = round((ytd26 - same25) / max(abs(same25), 1) * 100)
|
||
fy25 = sum(a2025.values())
|
||
|
||
out = {
|
||
'generated': datetime.now(timezone.utc).isoformat(),
|
||
'data_source': source,
|
||
'revenue': {
|
||
'actuals_2025': a2025,
|
||
'actuals_2026': a2026,
|
||
'ytd_2026': ytd26,
|
||
'ytd_2025_same_period': same25,
|
||
'full_year_2025': fy25,
|
||
'remaining_to_beat_2025': max(0, fy25 - ytd26),
|
||
'yoy_pct': yoy,
|
||
},
|
||
'mrr': {
|
||
'arpu': 79,
|
||
'active_venues': 1,
|
||
'current_mrr': 79,
|
||
'target_venues': 52,
|
||
'mrr_at_target': 52 * 79,
|
||
},
|
||
'forecast_h2': monte_carlo(a2026),
|
||
'ltv': ltv(),
|
||
'market': {
|
||
'verified_facts': [
|
||
{'label':'NL GDP growth','value':'+1.4%','source':'CPB 2025'},
|
||
{'label':'Horeca turnover growth','value':'+3.4%','source':'CBS 2024'},
|
||
{'label':'Real consumer spending','value':'+1–1.5%','source':'ING 2025'},
|
||
{'label':'Contactless payments','value':'94%','source':'DNB end-2024'},
|
||
{'label':'Pin transactions 2024','value':'5.77B (+2.9%)','source':'Betaalvereniging NL'},
|
||
],
|
||
'tam_tilburg_horeca': '~600 venues',
|
||
'ccv_entry_price': '€25–€34.50/mo',
|
||
'expansion_verdict': 'Sign 1 AtlasPOS venue/week → 52 venues by Jun 2027 → €4,108/mo MRR',
|
||
},
|
||
}
|
||
|
||
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||
with open(OUT_PATH, 'w') as f:
|
||
json.dump(out, f, indent=2)
|
||
print(f'[intelligence] Written → {OUT_PATH}')
|
||
print(f'[intelligence] YTD 2026: €{ytd26:,} | YoY: {yoy:+}% | source: {source}')
|
||
|
||
if __name__ == '__main__':
|
||
main()
|