ai-marketing-skills/finance-ops/scripts/cfo-analyzer.py
Alfred Claw a96d0d8889 Initial commit: 6 AI marketing skill categories
- growth-engine: Autonomous experiment engine (Karpathy autoresearch for marketing)
- sales-pipeline: RB2B router, deal resurrector, trigger prospector, ICP learner
- content-ops: Expert panel, quality gate, editorial brain, quote miner
- outbound-engine: Cold outbound optimizer, lead pipeline, competitive monitor
- seo-ops: Content attack briefs, GSC optimizer, trend scout
- finance-ops: CFO briefing, cost estimate, scenario modeler

79 files, all sanitized - zero hardcoded credentials or internal references.
2026-03-27 20:14:52 -07:00

821 lines
29 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
"""
CFO Briefing Analyzer — Parse QuickBooks exports and generate executive financial summaries.
Outputs formatted briefing to stdout. Stores history for MoM comparison.
Usage:
python3 cfo-analyzer.py --input ./data/uploads/
python3 cfo-analyzer.py --input ./data/uploads/ --period 2025-01
python3 cfo-analyzer.py --input ./data/uploads/ --no-history
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
import pandas as pd
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def parse_dollar(val: Any) -> float:
"""Convert QB dollar string to float. Handles $, commas, parens for negatives, formula strings."""
if val is None:
return 0.0
if isinstance(val, (int, float)):
return float(val)
s = str(val).strip()
if not s or s == '-' or s.lower() == 'none':
return 0.0
# Handle Excel formula strings like =-236705.50
if s.startswith('='):
s = s[1:]
try:
return float(s)
except ValueError:
return 0.0
neg = False
if s.startswith('(') and s.endswith(')'):
neg = True
s = s[1:-1]
s = s.replace('$', '').replace(',', '').replace('"', '').strip()
if not s:
return 0.0
try:
v = float(s)
return -v if neg else v
except ValueError:
return 0.0
def status_emoji(value: float, green_range: tuple, yellow_range: tuple) -> str:
"""Return 🟢/🟡/🔴 based on thresholds. green_range/yellow_range are (min, max) inclusive."""
gmin, gmax = green_range
ymin, ymax = yellow_range
if gmin <= value <= gmax:
return "🟢"
if ymin <= value <= ymax:
return "🟡"
return "🔴"
def pct(part: float, whole: float) -> float:
return (part / whole * 100) if whole else 0.0
def fmt_k(val: float) -> str:
"""Format as $XXK or $X.XM."""
if abs(val) >= 1_000_000:
return f"${val/1_000_000:.2f}M"
return f"${val/1_000:.0f}K"
def fmt_pct(val: float) -> str:
return f"{val:.1f}%"
# ---------------------------------------------------------------------------
# File detection & parsing
# ---------------------------------------------------------------------------
def detect_file_type(filepath: Path) -> str | None:
"""Detect QB report type from file content."""
ext = filepath.suffix.lower()
if ext in ('.xlsx', '.xls'):
try:
import openpyxl
wb = openpyxl.load_workbook(str(filepath), data_only=False)
for name in wb.sheetnames:
nl = name.lower()
if 'cash flow' in nl or 'statement of cash' in nl:
return 'cash_flow'
if 'profit and loss detail' in nl or 'p&l detail' in nl:
return 'pl_detail'
if 'profit and loss' in nl or 'p&l' in nl:
return 'pl_summary'
if 'balance sheet' in nl:
return 'balance_sheet'
if 'general ledger' in nl:
return 'general_ledger'
ws = wb.active
for row in ws.iter_rows(max_row=5, values_only=True):
text = ' '.join(str(c).lower() for c in row if c)
if 'statement of cash flow' in text:
return 'cash_flow'
if 'profit and loss detail' in text:
return 'pl_detail'
if 'profit and loss' in text:
return 'pl_summary'
if 'balance sheet' in text:
return 'balance_sheet'
except Exception:
pass
return None
if ext != '.csv':
return None
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
head = ''.join(f.readline() for _ in range(10)).lower()
except Exception:
return None
if 'profit and loss by customer' in head or 'profit and loss by job' in head:
return 'pl_by_customer'
if 'profit and loss' in head:
return 'pl_summary'
if 'balance sheet' in head:
return 'balance_sheet'
if 'general ledger' in head:
return 'general_ledger'
if 'expenses by vendor' in head:
return 'expenses_by_vendor'
if 'transaction list by vendor' in head:
return 'transactions_by_vendor'
if 'bill payment' in head:
return 'bill_payments'
if 'account list' in head:
return 'account_list'
return None
def detect_period(filepath: Path) -> str | None:
"""Try to extract period from file header."""
ext = filepath.suffix.lower()
text = ''
if ext == '.csv':
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
text = ''.join(f.readline() for _ in range(5))
except Exception:
pass
elif ext in ('.xlsx', '.xls'):
try:
import openpyxl
wb = openpyxl.load_workbook(str(filepath), data_only=False)
ws = wb.active
for row in ws.iter_rows(max_row=5, values_only=True):
text += ' '.join(str(c) for c in row if c) + '\n'
except Exception:
pass
# Look for patterns like "February, 2025-January, 2026" or "March 2025"
m = re.search(r'(\w+),?\s*(\d{4})\s*[-]\s*(\w+),?\s*(\d{4})', text)
if m:
end_month, end_year = m.group(3), m.group(4)
try:
dt = datetime.strptime(f"{end_month} {end_year}", "%B %Y")
return dt.strftime("%Y-%m")
except ValueError:
pass
m = re.search(r'(\w+ \d{4})', text)
if m:
try:
dt = datetime.strptime(m.group(1), "%B %Y")
return dt.strftime("%Y-%m")
except ValueError:
pass
return None
# ---------------------------------------------------------------------------
# P&L Summary parser
# ---------------------------------------------------------------------------
def parse_pl_summary(filepath: Path) -> dict:
"""Parse P&L Summary CSV into structured data."""
results: dict[str, Any] = {
'revenue': {},
'cogs': {},
'expenses': {},
'other_income': {},
'other_expenses': {},
'totals': {}
}
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
section = None
for line in lines:
line = line.strip()
if not line:
continue
# Split on first comma that's not inside quotes
parts = []
in_quote = False
current = ''
for ch in line:
if ch == '"':
in_quote = not in_quote
elif ch == ',' and not in_quote:
parts.append(current.strip().strip('"'))
current = ''
continue
current += ch
parts.append(current.strip().strip('"'))
account = parts[0] if parts else ''
value = parse_dollar(parts[-1]) if len(parts) > 1 else 0.0
# Track sections
if account == 'Income':
section = 'revenue'
continue
elif account == 'Cost of Goods Sold':
section = 'cogs'
continue
elif account == 'Expenses':
section = 'expenses'
continue
elif account == 'Other Income':
section = 'other_income'
continue
elif account == 'Other Expenses':
section = 'other_expenses'
continue
# Capture totals
if account.startswith('Total for Income'):
results['totals']['total_income'] = value
section = None
elif account.startswith('Total for Cost of Goods Sold'):
results['totals']['total_cogs'] = value
elif account.startswith('Gross Profit'):
results['totals']['gross_profit'] = value
elif account.startswith('Total for Expenses'):
results['totals']['total_expenses'] = value
elif account == 'Net Operating Income':
results['totals']['net_operating_income'] = value
elif account.startswith('Total for Other Income'):
results['totals']['total_other_income'] = value
elif account.startswith('Total for Other Expenses'):
results['totals']['total_other_expenses'] = value
elif account == 'Net Income':
results['totals']['net_income'] = value
elif account.startswith('Net Other Income'):
results['totals']['net_other_income'] = value
elif section and value != 0.0 and not account.startswith('Total for'):
# Store individual line items
clean_name = re.sub(r'^\d{5}\s+', '', account).strip()
if section in results:
results[section][clean_name] = value
return results
# ---------------------------------------------------------------------------
# P&L by Customer parser
# ---------------------------------------------------------------------------
def parse_pl_by_customer(filepath: Path) -> dict:
"""Parse P&L by Customer CSV. Returns revenue by customer."""
customers: dict[str, float] = {}
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
lines = f.readlines()
# Find header row with customer names
header_idx = None
headers = []
for i, line in enumerate(lines):
if 'Distribution account' in line:
header_idx = i
import csv
reader = csv.reader([line])
headers = next(reader)
break
if not headers:
return {'customers': customers}
# Find Total for Income row
for line in lines[header_idx+1:]:
if 'Total for Income' in line or 'Total for' in line and 'Revenue' in line:
import csv
reader = csv.reader([line])
values = next(reader)
for j, val in enumerate(values):
if j > 0 and j < len(headers) and headers[j] and headers[j] != 'Total':
v = parse_dollar(val)
if v > 0:
name = headers[j].replace(' (deleted)', '').strip()
customers[name] = v
break
return {'customers': customers}
# ---------------------------------------------------------------------------
# Cash Flow XLSX parser
# ---------------------------------------------------------------------------
def parse_cash_flow(filepath: Path) -> dict:
"""Parse Cash Flow Statement XLSX."""
import openpyxl
wb = openpyxl.load_workbook(str(filepath), data_only=False)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
result: dict[str, Any] = {'monthly_net_income': {}, 'monthly_net_cash': {}}
# Find header row with month columns
headers = []
header_row_idx = None
for i, row in enumerate(rows):
vals = [str(c) for c in row if c]
for v in vals:
if re.search(r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{4}', str(v)):
headers = list(row)
header_row_idx = i
break
if headers:
break
if not headers:
return result
for row in rows[header_row_idx+1:]:
label = str(row[0]).strip() if row[0] else ''
if 'Net Income' in label and 'reconcile' not in label.lower():
for j, val in enumerate(row[1:], 1):
if j < len(headers) and headers[j]:
month_str = str(headers[j])
v = parse_dollar(val)
result['monthly_net_income'][month_str] = v
if 'net cash' in label.lower() or 'net change in cash' in label.lower():
for j, val in enumerate(row[1:], 1):
if j < len(headers) and headers[j]:
month_str = str(headers[j])
v = parse_dollar(val)
result['monthly_net_cash'][month_str] = v
return result
# ---------------------------------------------------------------------------
# KPI computation
# ---------------------------------------------------------------------------
def compute_kpis(pl_data: dict, customer_data: dict | None, cash_flow_data: dict | None) -> dict:
"""Compute all KPIs from parsed data."""
kpis: dict[str, Any] = {}
totals = pl_data.get('totals', {})
revenue_items = pl_data.get('revenue', {})
cogs_items = pl_data.get('cogs', {})
expense_items = pl_data.get('expenses', {})
other_exp = pl_data.get('other_expenses', {})
other_inc = pl_data.get('other_income', {})
# --- Revenue ---
total_revenue = totals.get('total_income', 0)
kpis['total_revenue'] = total_revenue
# Revenue by service line
service_revenue = {}
for k, v in revenue_items.items():
if v != 0:
service_revenue[k] = v
kpis['revenue_by_service'] = dict(sorted(service_revenue.items(), key=lambda x: -x[1]))
# --- COGS & Gross Margin ---
total_cogs = totals.get('total_cogs', 0)
gross_profit = totals.get('gross_profit', total_revenue - total_cogs)
kpis['total_cogs'] = total_cogs
kpis['gross_profit'] = gross_profit
kpis['gross_margin_pct'] = pct(gross_profit, total_revenue)
# --- Net Income ---
kpis['net_income'] = totals.get('net_income', 0)
kpis['net_operating_income'] = totals.get('net_operating_income', 0)
kpis['net_margin_pct'] = pct(kpis['net_income'], total_revenue)
# --- People Costs ---
salary_total = 0
contractor_total = 0
payroll_tax_total = 0
benefits_total = 0
all_items = {}
all_items.update(cogs_items)
all_items.update(expense_items)
for k, v in all_items.items():
kl = k.lower()
if 'salari' in kl or 'wages' in kl or 'payroll -' in kl or 'payroll - ' in kl:
salary_total += v
elif 'contractor' in kl:
contractor_total += v
elif 'payroll tax' in kl:
payroll_tax_total += v
elif 'benefit' in kl or 'insurance' in kl and 'benefit' in kl:
benefits_total += v
elif 'commission' in kl:
contractor_total += v
people_total = salary_total + contractor_total + payroll_tax_total + benefits_total
kpis['people_costs'] = {
'total': people_total,
'salaries': salary_total,
'contractors': contractor_total,
'payroll_taxes': payroll_tax_total,
'benefits': benefits_total,
'pct_of_revenue': pct(people_total, total_revenue),
'contractor_pct': pct(contractor_total, people_total) if people_total else 0
}
# --- Tool/Subscription Costs ---
tool_total = 0
tool_items = {}
for k, v in all_items.items():
kl = k.lower()
if any(w in kl for w in ['subscription', 'tools', 'hosting', 'it hosting', 'software']):
tool_total += v
tool_items[k] = v
kpis['tool_costs'] = {
'total': tool_total,
'pct_of_revenue': pct(tool_total, total_revenue),
'items': dict(sorted(tool_items.items(), key=lambda x: -x[1]))
}
# --- Expense Categories ---
total_expenses = totals.get('total_expenses', 0)
kpis['total_opex'] = total_expenses
categories = {
'Sales & Growth': 0,
'Marketing & Branding': 0,
'G&A': 0,
'Facilities': 0,
'Other Expenses': 0,
}
for k, v in expense_items.items():
kl = k.lower()
if any(w in kl for w in ['sales', 'commission', 'growth']):
categories['Sales & Growth'] += v
elif any(w in kl for w in ['marketing', 'advertising', 'writing', 'content', 'podcast', 'branding', 'networking']):
categories['Marketing & Branding'] += v
elif any(w in kl for w in ['rent', 'facilit']):
categories['Facilities'] += v
else:
categories['G&A'] += v
kpis['expense_categories'] = {k: {'amount': v, 'pct_of_revenue': pct(v, total_revenue)} for k, v in categories.items() if v}
# --- Interest & Debt ---
interest = other_exp.get('Interest Expense', 0)
amortization = other_exp.get('Amortization Expense', 0)
kpis['interest_expense'] = interest
kpis['amortization'] = amortization
kpis['interest_pct_of_revenue'] = pct(interest, total_revenue)
# --- Other Income ---
kpis['other_income'] = {k: v for k, v in other_inc.items() if v}
kpis['total_other_income'] = totals.get('total_other_income', 0)
# --- Notable Expense Items (anomaly detection) ---
notable = {}
for k, v in all_items.items():
kl = k.lower()
if v > 5000 and not any(w in kl for w in ['salari', 'wages', 'payroll', 'rent', 'amortization']):
notable[k] = v
kpis['notable_expenses'] = dict(sorted(notable.items(), key=lambda x: -x[1])[:15])
# --- Recruiting ---
recruiting = 0
for k, v in all_items.items():
if 'recruit' in k.lower():
recruiting += v
kpis['recruiting_spend'] = recruiting
# --- Owner Expenses ---
owner_total = 0
for k, v in all_items.items():
if 'owner' in k.lower() or k.startswith('OE -'):
owner_total += v
kpis['owner_expenses'] = owner_total
# --- Customer Data ---
if customer_data and customer_data.get('customers'):
custs = customer_data['customers']
sorted_custs = sorted(custs.items(), key=lambda x: -x[1])
kpis['top_customers'] = sorted_custs[:10]
kpis['customer_count'] = len([c for c in custs.values() if c > 0])
if sorted_custs and total_revenue:
top_pct = sorted_custs[0][1] / total_revenue * 100
kpis['top_customer_concentration'] = (sorted_custs[0][0], top_pct)
# --- Cash Flow Data ---
if cash_flow_data:
monthly_ni = cash_flow_data.get('monthly_net_income', {})
if monthly_ni:
vals = list(monthly_ni.values())
kpis['monthly_net_income'] = monthly_ni
if len(vals) >= 3:
kpis['last_3mo_avg_ni'] = sum(vals[-3:]) / 3
kpis['monthly_burn'] = abs(min(vals)) if any(v < 0 for v in vals) else 0
return kpis
# ---------------------------------------------------------------------------
# MoM comparison
# ---------------------------------------------------------------------------
def load_prior_period(history_dir: Path, current_period: str) -> dict | None:
"""Load most recent prior period from history."""
if not history_dir.exists():
return None
files = sorted(history_dir.glob('*.json'), reverse=True)
for f in files:
if f.stem != current_period:
try:
with open(f) as fh:
return json.load(fh)
except Exception:
continue
return None
def compute_variance(current: float, prior: float) -> str:
"""Format MoM variance."""
if prior == 0:
return "N/A"
change = ((current - prior) / abs(prior)) * 100
arrow = "" if change > 0 else "" if change < 0 else ""
return f"{arrow} {abs(change):.1f}%"
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def format_briefing(kpis: dict, prior: dict | None, period: str) -> str:
"""Format KPIs into executive briefing with status indicators."""
lines = []
lines.append(f"*📊 CFO Briefing — {period}*")
lines.append("=" * 40)
rev = kpis['total_revenue']
# --- Revenue ---
lines.append("")
lines.append("*💰 Revenue*")
lines.append(f"• Total Revenue: *{fmt_k(rev)}*" + (f" ({compute_variance(rev, prior.get('total_revenue', 0))} MoM)" if prior else ""))
if kpis.get('revenue_by_service'):
top3 = list(kpis['revenue_by_service'].items())[:5]
for name, val in top3:
lines.append(f" · {name}: {fmt_k(val)} ({fmt_pct(pct(val, rev))})")
# --- Profitability ---
gm = kpis['gross_margin_pct']
nm = kpis['net_margin_pct']
gm_status = status_emoji(gm, (60, 999), (45, 60))
nm_status = status_emoji(nm, (10, 999), (0, 10))
lines.append("")
lines.append("*📈 Profitability*")
lines.append(f"{gm_status} Gross Margin: *{fmt_pct(gm)}* (Gross Profit: {fmt_k(kpis['gross_profit'])})")
lines.append(f"{nm_status} Net Income: *{fmt_k(kpis['net_income'])}* ({fmt_pct(nm)} margin)")
lines.append(f"• Net Operating Income: {fmt_k(kpis['net_operating_income'])}")
if prior:
lines.append(f" MoM: Revenue {compute_variance(rev, prior.get('total_revenue', 0))}, "
f"Net Income {compute_variance(kpis['net_income'], prior.get('net_income', 0))}")
# --- People Costs ---
pc = kpis['people_costs']
pc_status = status_emoji(pc['pct_of_revenue'], (0, 65), (65, 75))
contr_status = status_emoji(pc['contractor_pct'], (0, 30), (30, 50))
lines.append("")
lines.append("*👥 People Costs*")
lines.append(f"{pc_status} Total: *{fmt_k(pc['total'])}* ({fmt_pct(pc['pct_of_revenue'])} of revenue)")
lines.append(f" · Salaries: {fmt_k(pc['salaries'])}")
lines.append(f" · {contr_status} Contractors: {fmt_k(pc['contractors'])} ({fmt_pct(pc['contractor_pct'])} of people costs)")
lines.append(f" · Payroll Taxes: {fmt_k(pc['payroll_taxes'])}")
lines.append(f" · Benefits: {fmt_k(pc['benefits'])}")
# --- Tools & Subscriptions ---
tc = kpis['tool_costs']
tc_status = status_emoji(tc['pct_of_revenue'], (0, 8), (8, 12))
lines.append("")
lines.append("*🔧 Tools & Subscriptions*")
lines.append(f"{tc_status} Total: *{fmt_k(tc['total'])}* ({fmt_pct(tc['pct_of_revenue'])} of revenue)")
if tc['items']:
for name, val in list(tc['items'].items())[:5]:
lines.append(f" · {name}: {fmt_k(val)}")
# --- Expense Categories ---
if kpis.get('expense_categories'):
lines.append("")
lines.append("*📋 Operating Expenses*")
lines.append(f"• Total OpEx: *{fmt_k(kpis['total_opex'])}* ({fmt_pct(pct(kpis['total_opex'], rev))} of revenue)")
for cat, data in kpis['expense_categories'].items():
lines.append(f" · {cat}: {fmt_k(data['amount'])} ({fmt_pct(data['pct_of_revenue'])})")
# --- Interest & Debt ---
int_status = status_emoji(kpis['interest_pct_of_revenue'], (0, 3), (3, 5))
lines.append("")
lines.append("*🏦 Debt & Interest*")
lines.append(f"{int_status} Interest Expense: *{fmt_k(kpis['interest_expense'])}* ({fmt_pct(kpis['interest_pct_of_revenue'])} of revenue)")
lines.append(f"• Amortization (non-cash): {fmt_k(kpis['amortization'])}")
# --- Notable Items ---
if kpis.get('recruiting_spend'):
lines.append(f"• Recruiting: {fmt_k(kpis['recruiting_spend'])}")
if kpis.get('owner_expenses'):
lines.append(f"• Owner Expenses: {fmt_k(kpis['owner_expenses'])}")
# --- Customer Concentration ---
if kpis.get('top_customers'):
lines.append("")
lines.append("*🏢 Top Customers*")
for name, val in kpis['top_customers'][:5]:
conc = pct(val, rev)
conc_flag = " ⚠️" if conc > 15 else ""
lines.append(f" · {name}: {fmt_k(val)} ({fmt_pct(conc)}){conc_flag}")
if kpis.get('top_customer_concentration'):
cname, cpct = kpis['top_customer_concentration']
conc_status = status_emoji(100 - cpct, (75, 100), (60, 75))
lines.append(f"{conc_status} Top client concentration: {cname} at {fmt_pct(cpct)}")
if kpis.get('customer_count'):
lines.append(f"• Active customers: {kpis['customer_count']}")
# --- Other Income ---
if kpis.get('other_income'):
lines.append("")
lines.append("*📥 Other Income*")
for k, v in kpis['other_income'].items():
lines.append(f" · {k}: {fmt_k(v)}")
# --- Monthly Trends ---
if kpis.get('monthly_net_income'):
lines.append("")
lines.append("*📉 Monthly Net Income Trend*")
for month, val in kpis['monthly_net_income'].items():
ml = month.lower()
if 'total' in ml:
continue
if val == 0.0:
continue
indicator = "" if val > 0 else ""
lines.append(f" · {month}: {fmt_k(val)} {indicator}")
# --- Alerts ---
alerts = []
if kpis['gross_margin_pct'] < 45:
alerts.append("🔴 Gross margin critically low (<45%). Target: 60%+")
if pc['pct_of_revenue'] > 75:
alerts.append("🔴 People costs >75% of revenue. Target: 55-65%")
if pc['contractor_pct'] > 50:
alerts.append("🟡 Contractor spend >50% of people costs — dependency risk")
if kpis['interest_pct_of_revenue'] > 5:
alerts.append("🔴 Interest >5% of revenue — debt load is heavy")
if kpis.get('recruiting_spend', 0) > 80000:
alerts.append("🟡 Recruiting spend elevated — review if active hires justify")
if kpis.get('owner_expenses', 0) > 100000:
alerts.append("🟡 Owner expenses >$100K TTM")
if kpis['net_income'] < 0:
deficit = abs(kpis['net_income'])
alerts.append(f"🔴 Operating at a loss: {fmt_k(deficit)} deficit")
if alerts:
lines.append("")
lines.append("*⚠️ Alerts*")
for a in alerts:
lines.append(f"{a}")
lines.append("")
lines.append("_Data from QuickBooks exports. Review with your finance team before acting._")
return '\n'.join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description='CFO Briefing Analyzer')
parser.add_argument('--input', '-i', default='./data/uploads/',
help='Directory containing QB export files')
parser.add_argument('--period', '-p', help='Override period label (YYYY-MM)')
parser.add_argument('--history', default='./data/history/',
help='History directory for MoM comparison')
parser.add_argument('--no-history', action='store_true', help='Skip saving to history')
args = parser.parse_args()
input_dir = Path(args.input)
history_dir = Path(args.history)
if not input_dir.exists():
print(f"Error: Input directory not found: {input_dir}", file=sys.stderr)
sys.exit(1)
# Find and classify files
files = list(input_dir.glob('*.csv')) + list(input_dir.glob('*.xlsx')) + list(input_dir.glob('*.xls'))
if not files:
print(f"Error: No CSV/XLSX files found in {input_dir}", file=sys.stderr)
sys.exit(1)
classified: dict[str, Path] = {}
period = args.period
for f in files:
ftype = detect_file_type(f)
if ftype:
classified[ftype] = f
if not period:
p = detect_period(f)
if p:
period = p
print(f" Detected: {f.name}{ftype}", file=sys.stderr)
else:
print(f" Skipped (unknown format): {f.name}", file=sys.stderr)
if not period:
period = datetime.now().strftime("%Y-%m")
print(f" Period: {period}", file=sys.stderr)
print(f" Files classified: {len(classified)}", file=sys.stderr)
# Parse files
pl_data = {}
customer_data = None
cash_flow_data = None
if 'pl_summary' in classified:
pl_data = parse_pl_summary(classified['pl_summary'])
elif 'pl_by_customer' not in classified:
print("Warning: No P&L file found. Output will be limited.", file=sys.stderr)
pl_data = {'revenue': {}, 'cogs': {}, 'expenses': {}, 'other_income': {}, 'other_expenses': {}, 'totals': {}}
if 'pl_by_customer' in classified:
customer_data = parse_pl_by_customer(classified['pl_by_customer'])
if not pl_data.get('totals'):
pl_data = parse_pl_summary(classified['pl_by_customer'])
if 'cash_flow' in classified:
cash_flow_data = parse_cash_flow(classified['cash_flow'])
# Compute KPIs
kpis = compute_kpis(pl_data, customer_data, cash_flow_data)
# Load prior period
prior = None
if history_dir.exists():
prior = load_prior_period(history_dir, period)
# Save current period
if not args.no_history:
history_dir.mkdir(parents=True, exist_ok=True)
history_file = history_dir / f"{period}.json"
save_data = {
'period': period,
'total_revenue': kpis['total_revenue'],
'gross_profit': kpis['gross_profit'],
'gross_margin_pct': kpis['gross_margin_pct'],
'net_income': kpis['net_income'],
'net_margin_pct': kpis['net_margin_pct'],
'total_cogs': kpis['total_cogs'],
'total_opex': kpis['total_opex'],
'people_costs_total': kpis['people_costs']['total'],
'people_costs_pct': kpis['people_costs']['pct_of_revenue'],
'tool_costs_total': kpis['tool_costs']['total'],
'tool_costs_pct': kpis['tool_costs']['pct_of_revenue'],
'interest_expense': kpis['interest_expense'],
'recruiting_spend': kpis.get('recruiting_spend', 0),
'owner_expenses': kpis.get('owner_expenses', 0),
'customer_count': kpis.get('customer_count', 0),
'timestamp': datetime.now().isoformat(),
}
with open(history_file, 'w') as f:
json.dump(save_data, f, indent=2)
print(f" Saved history: {history_file}", file=sys.stderr)
# Output briefing
briefing = format_briefing(kpis, prior, period)
print(briefing)
if __name__ == '__main__':
main()