import csv import io import json import logging import os from collections import defaultdict from datetime import datetime from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo import boto3 from shared.db import current_week, get_orders, get_summary, put_summary from shared.pdf import build_weekly_summary_pdf EASTERN = ZoneInfo("America/New_York") logger = logging.getLogger() logger.setLevel(logging.INFO) _s3 = boto3.client("s3") _lambda = boto3.client("lambda") _sqs = boto3.client("sqs") KIND_MEAL = "meal_deduction" class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) def lambda_handler(event, context): week = event.get("week", current_week()) existing = get_summary(week) if existing: return {"status": "already_aggregated", "week": week} orders = get_orders(week) if not orders: return {"status": "no_orders", "week": week} summary = build_summary(orders, week) order_csv = build_order_summary_csv(summary) payroll_csv = build_payroll_csv(orders) summary_pdf = build_weekly_summary_pdf( orders, week=week, generated_at=summary["generated_at"], total_employees=summary["total_employees"], total_meals=summary["total_meals"], ) bucket = os.environ["REPORTS_BUCKET"] order_csv_key = f"reports/{week}/order-summary.csv" payroll_csv_key = f"reports/{week}/payroll-deductions.csv" summary_pdf_key = f"reports/{week}/weekly-summary-{week}.pdf" _s3.put_object( Bucket=bucket, Key=order_csv_key, Body=order_csv, ContentType="text/csv" ) _s3.put_object( Bucket=bucket, Key=payroll_csv_key, Body=payroll_csv, ContentType="text/csv" ) _s3.put_object( Bucket=bucket, Key=summary_pdf_key, Body=summary_pdf, ContentType="application/pdf", ) summary["order_csv_s3_key"] = order_csv_key summary["payroll_csv_s3_key"] = payroll_csv_key summary["weekly_summary_pdf_s3_key"] = summary_pdf_key put_summary(week, summary) try: _send_checkcomponents(week, orders) except Exception: logger.exception("checkcomponents send failed week=%s", week) _lambda.invoke( FunctionName=os.environ["SLACK_NOTIFIER_ARN"], InvocationType="Event", Payload=json.dumps( {"event": "orders_aggregated", "week": week}, cls=DecimalEncoder ), ) return {"status": "aggregated", "week": week, "total_employees": len(orders)} def build_checkcomponents_payload(week: str, orders: list[dict]) -> dict: """Sibling dollar lines only. No Flex OAuth, no payPeriodId invention.""" lines = [] for order in orders: email = str(order.get("employee_email") or "").strip() if not email: continue amount = Decimal(str(order.get("total") or 0)) lines.append( { "employee_email": email, "amount": format( amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), "f" ), } ) week_token = week if str(week).upper().startswith("WEEK#") else f"WEEK#{week}" return { "type": "checkcomponents", "kind": KIND_MEAL, "week": week_token, "lines": lines, } def _send_checkcomponents(week: str, orders: list[dict]) -> None: queue_url = os.environ.get("CHECKCOMPONENTS_QUEUE_URL", "").strip() if not queue_url: return payload = build_checkcomponents_payload(week, orders) if not payload["lines"]: return _sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(payload)) def build_summary(orders: list[dict], week: str) -> dict: meal_totals = defaultdict( lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0} ) for order in orders: for item in order.get("items", []): name = item["name"] qty = int(item.get("quantity", 0)) meal_totals[name]["quantity"] += qty meal_totals[name]["bulk_price"] = float( item.get("bulk_price", item.get("price", 0)) ) meal_totals[name]["employee_price"] = float(item.get("price", 0)) meals = [] for name, data in sorted(meal_totals.items()): meals.append( { "meal": name, "quantity": data["quantity"], "unit_price": data["bulk_price"], "employee_unit_price": data["employee_price"], "line_total": round(data["bulk_price"] * data["quantity"], 2), "employee_line_total": round( data["employee_price"] * data["quantity"], 2 ), } ) return { "week": week, "generated_at": datetime.now(EASTERN).isoformat(), "total_employees": len(orders), "total_meals": sum(m["quantity"] for m in meals), "grand_total": round(sum(m["line_total"] for m in meals), 2), "employee_total": round(sum(m["employee_line_total"] for m in meals), 2), "meals": meals, } def build_order_summary_csv(summary: dict) -> str: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(["Meal", "Quantity", "Unit Price", "Line Total"]) for meal in summary["meals"]: writer.writerow( [ meal["meal"], meal["quantity"], f"${meal['unit_price']:.2f}", f"${meal['line_total']:.2f}", ] ) writer.writerow([]) writer.writerow( ["TOTAL", summary["total_meals"], "", f"${summary['grand_total']:.2f}"] ) return buf.getvalue() def build_payroll_csv(orders: list[dict]) -> str: buf = io.StringIO() writer = csv.writer(buf) writer.writerow( ["Employee Name", "Employee Email", "Items Ordered", "Total Deduction"] ) for order in sorted(orders, key=lambda o: o["employee_name"]): items_str = "; ".join( f"{item['name']} x{int(item['quantity'])} (${float(item.get('subtotal', float(item.get('price', 0)) * int(item.get('quantity', 0)))):.2f})" for item in order.get("items", []) ) writer.writerow( [ order["employee_name"], order["employee_email"], items_str, f"${float(order['total']):.2f}", ] ) return buf.getvalue()