2026-04-07 18:09:19 -04:00
|
|
|
|
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
2026-04-07 18:54:19 -04:00
|
|
|
|
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
|
2026-04-03 18:32:32 -04:00
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
from datetime import datetime, timedelta
|
2026-04-07 18:59:52 -04:00
|
|
|
|
from decimal import Decimal
|
2026-04-03 18:32:32 -04:00
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
|
|
import boto3
|
|
|
|
|
|
from slack_sdk import WebClient
|
|
|
|
|
|
|
2026-04-07 18:09:19 -04:00
|
|
|
|
from src.blocks import build_pay_summary_blocks, build_week_schedule
|
2026-04-07 18:22:56 -04:00
|
|
|
|
from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
2026-04-03 18:32:32 -04:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger()
|
|
|
|
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
|
|
|
|
|
|
EASTERN = ZoneInfo("America/New_York")
|
|
|
|
|
|
|
2026-04-07 18:09:19 -04:00
|
|
|
|
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 18:22:56 -04:00
|
|
|
|
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
|
|
|
|
|
|
"""Resolve a shift and add it to the pay breakdown if assigned."""
|
|
|
|
|
|
date_str = date.strftime("%Y-%m-%d")
|
|
|
|
|
|
ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type)
|
|
|
|
|
|
|
|
|
|
|
|
if source == "available" or ext == FALLBACK_EXTENSION:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
rate = schedule.get_shift_rate(ext)
|
|
|
|
|
|
label_suffix = " (Day)" if shift_type == "day" else ""
|
|
|
|
|
|
|
|
|
|
|
|
breakdown.append({
|
|
|
|
|
|
"date": date_str,
|
|
|
|
|
|
"date_label": date.strftime("%b %-d"),
|
|
|
|
|
|
"day": f"{day_name[:3]}{label_suffix}",
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"extension": ext,
|
2026-04-07 18:59:52 -04:00
|
|
|
|
"rate": Decimal(str(rate)),
|
2026-04-07 18:22:56 -04:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if name not in totals:
|
2026-04-07 18:59:52 -04:00
|
|
|
|
totals[name] = {"shifts": 0, "total": Decimal("0"), "extension": ext, "rate": Decimal(str(rate))}
|
2026-04-07 18:22:56 -04:00
|
|
|
|
totals[name]["shifts"] += 1
|
2026-04-07 18:59:52 -04:00
|
|
|
|
totals[name]["total"] += Decimal(str(rate))
|
2026-04-07 18:22:56 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 18:09:19 -04:00
|
|
|
|
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
|
|
|
|
|
|
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
|
2026-04-07 18:12:32 -04:00
|
|
|
|
default_rate = schedule.get_shift_rate()
|
2026-04-07 18:09:19 -04:00
|
|
|
|
breakdown = []
|
|
|
|
|
|
totals = {}
|
|
|
|
|
|
|
|
|
|
|
|
for i in range(7):
|
|
|
|
|
|
date = week_start + timedelta(days=i)
|
|
|
|
|
|
day_name = DAY_ORDER[i]
|
2026-04-07 18:22:56 -04:00
|
|
|
|
|
|
|
|
|
|
# Weekend day shifts (8am–5pm)
|
|
|
|
|
|
if day_name in WEEKEND_DAYS:
|
|
|
|
|
|
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "day")
|
|
|
|
|
|
|
|
|
|
|
|
# After-hours (night) shift
|
|
|
|
|
|
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "night")
|
2026-04-07 18:09:19 -04:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"week_start": week_start.strftime("%Y-%m-%d"),
|
2026-04-07 18:12:32 -04:00
|
|
|
|
"default_rate": str(default_rate),
|
2026-04-07 18:09:19 -04:00
|
|
|
|
"breakdown": breakdown,
|
|
|
|
|
|
"totals": totals,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 18:32:32 -04:00
|
|
|
|
|
2026-04-07 18:54:19 -04:00
|
|
|
|
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
|
|
|
|
|
|
"""Build an HTML email body for the weekly pay summary."""
|
|
|
|
|
|
totals_rows = ""
|
|
|
|
|
|
for name, info in sorted(pay_record["totals"].items()):
|
|
|
|
|
|
totals_rows += (
|
2026-04-08 13:22:28 -04:00
|
|
|
|
f"<tr><td>{name}</td>"
|
2026-04-07 18:54:19 -04:00
|
|
|
|
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return f"""<html>
|
|
|
|
|
|
<body style="font-family: Arial, sans-serif; color: #333;">
|
2026-04-07 19:07:44 -04:00
|
|
|
|
<h2>Bonus Pay Summary — {week_label}</h2>
|
2026-04-07 18:54:19 -04:00
|
|
|
|
|
|
|
|
|
|
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
2026-04-08 13:22:28 -04:00
|
|
|
|
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
|
2026-04-07 18:54:19 -04:00
|
|
|
|
{totals_rows}</table>
|
|
|
|
|
|
|
2026-04-08 13:22:28 -04:00
|
|
|
|
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
|
2026-04-07 18:54:19 -04:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _send_pay_email(week_label: str, pay_record: dict) -> None:
|
|
|
|
|
|
"""Send the weekly pay summary email via SES."""
|
|
|
|
|
|
sender = os.environ.get("SES_SENDER", "noreply@seahaven.com")
|
|
|
|
|
|
recipients = os.environ.get("PAYROLL_RECIPIENTS", "").split(",")
|
|
|
|
|
|
recipients = [r.strip() for r in recipients if r.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
if not recipients:
|
|
|
|
|
|
logger.warning("No PAYROLL_RECIPIENTS configured — skipping email")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
ses = boto3.client("ses")
|
|
|
|
|
|
html_body = _build_pay_email_html(week_label, pay_record)
|
|
|
|
|
|
|
|
|
|
|
|
ses.send_email(
|
|
|
|
|
|
Source=sender,
|
|
|
|
|
|
Destination={"ToAddresses": recipients},
|
|
|
|
|
|
Message={
|
2026-04-07 19:07:44 -04:00
|
|
|
|
"Subject": {"Data": f"Bonus Pay Summary — {week_label}"},
|
2026-04-07 18:54:19 -04:00
|
|
|
|
"Body": {"Html": {"Data": html_body}},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("Sent pay email to %s", recipients)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-03 18:32:32 -04:00
|
|
|
|
def handler(event, context):
|
|
|
|
|
|
now = datetime.now(EASTERN)
|
|
|
|
|
|
|
|
|
|
|
|
# DST guard — same pattern as the 3CX scheduler
|
2026-04-07 18:58:03 -04:00
|
|
|
|
force = event.get("force", False)
|
|
|
|
|
|
if not force and now.hour != 7:
|
2026-04-03 18:32:32 -04:00
|
|
|
|
logger.info("ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour)
|
|
|
|
|
|
return {"skipped": True}
|
|
|
|
|
|
|
|
|
|
|
|
ssm = boto3.client("ssm")
|
|
|
|
|
|
bot_token = ssm.get_parameter(
|
|
|
|
|
|
Name=os.environ["SLACK_BOT_TOKEN_PARAM"], WithDecryption=True
|
|
|
|
|
|
)["Parameter"]["Value"]
|
|
|
|
|
|
channel_id = ssm.get_parameter(
|
|
|
|
|
|
Name=os.environ["SHIFT_CHANNEL_PARAM"], WithDecryption=True
|
|
|
|
|
|
)["Parameter"]["Value"]
|
|
|
|
|
|
|
|
|
|
|
|
schedule = ShiftSchedule()
|
|
|
|
|
|
slack = WebClient(token=bot_token)
|
|
|
|
|
|
|
2026-04-07 18:09:19 -04:00
|
|
|
|
# --- Previous week's pay summary ---
|
|
|
|
|
|
prev_monday = now - timedelta(days=7)
|
|
|
|
|
|
prev_monday = prev_monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
pay_record = _calculate_weekly_pay(schedule, prev_monday)
|
|
|
|
|
|
|
2026-04-07 19:07:44 -04:00
|
|
|
|
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
|
|
|
|
|
|
2026-04-07 18:09:19 -04:00
|
|
|
|
if pay_record["breakdown"]:
|
|
|
|
|
|
week_key = prev_monday.strftime("%Y-%m-%d")
|
|
|
|
|
|
schedule.save_pay_record(week_key, pay_record)
|
|
|
|
|
|
|
|
|
|
|
|
prev_sunday = prev_monday + timedelta(days=6)
|
|
|
|
|
|
week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
|
|
|
|
|
pay_blocks = build_pay_summary_blocks(
|
2026-04-07 18:12:32 -04:00
|
|
|
|
week_label, pay_record["breakdown"], pay_record["totals"]
|
2026-04-07 18:09:19 -04:00
|
|
|
|
)
|
2026-04-07 19:07:44 -04:00
|
|
|
|
|
|
|
|
|
|
# DM pay report to designated user instead of channel
|
|
|
|
|
|
if pay_dm_user:
|
|
|
|
|
|
slack.chat_postMessage(
|
|
|
|
|
|
channel=pay_dm_user,
|
|
|
|
|
|
blocks=pay_blocks,
|
|
|
|
|
|
text=f"After-Hours Pay — {week_label}",
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info("Sent pay summary DM to %s for week of %s", pay_dm_user, week_key)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
2026-04-07 18:09:19 -04:00
|
|
|
|
|
2026-04-07 18:54:19 -04:00
|
|
|
|
# Email pay summary to payroll
|
|
|
|
|
|
_send_pay_email(week_label, pay_record)
|
|
|
|
|
|
|
2026-04-08 13:36:34 -04:00
|
|
|
|
# --- Two-week schedule (always starts on Monday of this week) ---
|
|
|
|
|
|
this_monday = now - timedelta(days=now.weekday())
|
|
|
|
|
|
blocks = build_week_schedule(schedule, start_date=this_monday)
|
2026-04-03 18:32:32 -04:00
|
|
|
|
|
2026-04-08 13:36:34 -04:00
|
|
|
|
end_date = this_monday + timedelta(days=13)
|
2026-04-03 18:32:32 -04:00
|
|
|
|
slack.chat_postMessage(
|
|
|
|
|
|
channel=channel_id,
|
|
|
|
|
|
blocks=blocks,
|
2026-04-07 18:01:10 -04:00
|
|
|
|
text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
|
2026-04-03 18:32:32 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("Posted weekly schedule to channel %s", channel_id)
|
2026-04-07 18:09:19 -04:00
|
|
|
|
return {"posted": True, "channel": channel_id, "pay_calculated": bool(pay_record["breakdown"])}
|