mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 15:53:13 +00:00
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
* 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.
207 lines
6.4 KiB
Python
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()
|