meal-order-manager/functions/aggregate_orders/handler.py
Adam Moussa 62f61f61d1
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
feat(meals): send weekly deductions to paychex checkcomponents (PLAT-154) (#184)
* feat(meals): send weekly deductions to paychex checkcomponents (PLAT-154)

* fix(meals): round checkcomponents amounts half-up

Keep SQS deduction amounts on the same rounding path as submit_order so extra-precision totals cannot diverge by a cent.
2026-09-03 22:04:02 +00:00

207 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, 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()