meal-order-manager/functions/aggregate_orders/handler.py

205 lines
6.4 KiB
Python

import csv
import io
import json
import logging
import os
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
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")), "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()