mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
Holiday day-shifts (08:00-17:00 ET) with N slots and 1.5x pay. A new afterhours-holiday-router Lambda, fired by per-holiday EventBridge Scheduler one-offs, repoints IVR 800 (key-0 + no-input/timeout) to holiday queue 802 and sets 802's membership to the day's assignees (ext 100 fallback when unfilled), reverting at 17:00. Pickups after a shift starts go through an admin Approve/Deny flow for both regular and holiday shifts. Pay (weekly post + /oncall pay) shows holiday rates distinctly. Adds HOLIDAY and PICKUP_REQUEST DynamoDB record types, scheduler IAM scoped to holiday-* schedules with conditioned PassRole, and the holiday-router function with a 60-day log group and error alarm.
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""Lambda handler — posts the weekly on-call schedule and previous week's pay summary
|
||
to Slack every Monday at 7am ET, and emails the pay summary to payroll."""
|
||
|
||
import logging
|
||
import os
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import boto3
|
||
from slack_sdk import WebClient
|
||
|
||
from shared.blocks import build_pay_summary_blocks, build_week_schedule
|
||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
||
from shared.secrets import get_secret
|
||
|
||
logger = logging.getLogger()
|
||
logger.setLevel(logging.INFO)
|
||
|
||
EASTERN = ZoneInfo("America/New_York")
|
||
|
||
DAY_ORDER = [
|
||
"Monday",
|
||
"Tuesday",
|
||
"Wednesday",
|
||
"Thursday",
|
||
"Friday",
|
||
"Saturday",
|
||
"Sunday",
|
||
]
|
||
|
||
|
||
def _record_pay_line(
|
||
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
|
||
):
|
||
"""Append a single breakdown line and roll it into per-employee totals.
|
||
|
||
``effective`` is the dollar value actually earned for this one shift
|
||
(base_rate * multiplier for holidays, base_rate otherwise). To stay
|
||
compatible with the existing Slack ``build_pay_summary_blocks`` consumer —
|
||
which treats ``rate`` as the already-multiplied per-shift pay — the line's
|
||
``rate`` is set to ``effective``; ``base_rate`` is preserved separately and
|
||
``amount`` mirrors ``effective``. All money is stored as ``Decimal``.
|
||
"""
|
||
line = {
|
||
"date": date.strftime("%Y-%m-%d"),
|
||
"date_label": date.strftime("%b %-d"),
|
||
"day": day_label,
|
||
"name": name,
|
||
"extension": ext,
|
||
"rate": Decimal(str(effective)),
|
||
"base_rate": Decimal(str(base_rate)),
|
||
"amount": Decimal(str(effective)),
|
||
}
|
||
breakdown.append(line)
|
||
|
||
if name not in totals:
|
||
totals[name] = {
|
||
"shifts": 0,
|
||
"total": Decimal("0"),
|
||
"extension": ext,
|
||
"rate": Decimal(str(base_rate)),
|
||
"holiday_shifts": 0,
|
||
}
|
||
totals[name]["shifts"] += 1
|
||
totals[name]["total"] += Decimal(str(effective))
|
||
if is_holiday:
|
||
totals[name]["holiday_shifts"] = totals[name].get("holiday_shifts", 0) + 1
|
||
return line
|
||
|
||
|
||
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.
|
||
|
||
Holidays are day-only, multi-assignee shifts paid at base_rate * multiplier;
|
||
every assignee gets a distinct breakdown line tagged with the holiday
|
||
multiplier and label. Regular shifts have a single assignee at multiplier 1.
|
||
"""
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||
|
||
assignees = ctx.get("assignees") or []
|
||
if not assignees:
|
||
return
|
||
|
||
label_suffix = " (Day)" if shift_type == "day" else ""
|
||
day_label = f"{day_name[:3]}{label_suffix}"
|
||
|
||
if ctx["kind"] == "holiday":
|
||
multiplier = Decimal(str(ctx.get("multiplier", 1)))
|
||
holiday_label = ctx.get("label", "")
|
||
for assignee in assignees:
|
||
ext = assignee["extension"]
|
||
name = assignee["name"]
|
||
if ext == FALLBACK_EXTENSION:
|
||
continue
|
||
base_rate = Decimal(str(schedule.get_shift_rate(ext)))
|
||
effective = base_rate * multiplier
|
||
line = _record_pay_line(
|
||
breakdown,
|
||
totals,
|
||
date=date,
|
||
day_label=day_label,
|
||
name=name,
|
||
ext=ext,
|
||
effective=effective,
|
||
base_rate=base_rate,
|
||
is_holiday=True,
|
||
)
|
||
line["multiplier"] = Decimal(str(multiplier))
|
||
line["is_holiday"] = True
|
||
line["holiday_label"] = holiday_label
|
||
return
|
||
|
||
# Regular (override/weekly) shift — single assignee.
|
||
assignee = assignees[0]
|
||
ext = assignee["extension"]
|
||
name = assignee["name"]
|
||
if ext == FALLBACK_EXTENSION:
|
||
return
|
||
|
||
rate = Decimal(str(schedule.get_shift_rate(ext)))
|
||
_record_pay_line(
|
||
breakdown,
|
||
totals,
|
||
date=date,
|
||
day_label=day_label,
|
||
name=name,
|
||
ext=ext,
|
||
effective=rate,
|
||
base_rate=rate,
|
||
is_holiday=False,
|
||
)
|
||
|
||
|
||
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
|
||
"""Calculate pay for a Mon–Sun week. Returns pay record dict."""
|
||
default_rate = schedule.get_shift_rate()
|
||
breakdown = []
|
||
totals = {}
|
||
|
||
for i in range(7):
|
||
date = week_start + timedelta(days=i)
|
||
day_name = DAY_ORDER[i]
|
||
date_str = date.strftime("%Y-%m-%d")
|
||
|
||
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
|
||
# day-only and may land on a weekday, so check the day shift whenever a
|
||
# holiday record exists for the date, not just on weekend days.
|
||
if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str):
|
||
_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")
|
||
|
||
return {
|
||
"week_start": week_start.strftime("%Y-%m-%d"),
|
||
"default_rate": str(default_rate),
|
||
"breakdown": breakdown,
|
||
"totals": totals,
|
||
}
|
||
|
||
|
||
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
|
||
"""Build an HTML email body for the weekly pay summary.
|
||
|
||
Holiday shifts are rendered distinctly: a per-shift breakdown section
|
||
highlights holiday rows (shaded, showing the multiplier and label) and the
|
||
totals table flags any employee who worked a holiday during the week.
|
||
"""
|
||
breakdown = pay_record.get("breakdown", [])
|
||
totals = pay_record.get("totals", {})
|
||
|
||
holiday_names = {line["name"] for line in breakdown if line.get("is_holiday")}
|
||
|
||
totals_rows = ""
|
||
for name, info in sorted(totals.items()):
|
||
holiday_tag = (
|
||
' <span style="color:#b8860b;">★ holiday</span>'
|
||
if name in holiday_names
|
||
else ""
|
||
)
|
||
totals_rows += (
|
||
f"<tr><td>{name}{holiday_tag}</td>"
|
||
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
|
||
)
|
||
|
||
breakdown_rows = ""
|
||
for line in breakdown:
|
||
if line.get("is_holiday"):
|
||
mult = line.get("multiplier", Decimal("1"))
|
||
label = line.get("holiday_label", "") or "Holiday"
|
||
base = line.get("base_rate", line["rate"])
|
||
row_style = ' style="background:#fff8e1;"'
|
||
detail = f"{label} — ${base:.2f} × {mult:.2f}x"
|
||
else:
|
||
row_style = ""
|
||
detail = f"${line['rate']:.2f}"
|
||
breakdown_rows += (
|
||
f"<tr{row_style}><td>{line['date_label']}</td>"
|
||
f"<td>{line['day']}</td>"
|
||
f"<td>{line['name']}</td>"
|
||
f"<td>{detail}</td>"
|
||
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
|
||
)
|
||
|
||
return f"""<html>
|
||
<body style="font-family: Arial, sans-serif; color: #333;">
|
||
<h2>Bonus Pay Summary — {week_label}</h2>
|
||
|
||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
||
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
|
||
{totals_rows}</table>
|
||
|
||
<h3>Shift Breakdown</h3>
|
||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
||
<tr style="background: #f0f0f0;"><th>Date</th><th>Day</th><th>Name</th><th>Detail</th><th>Amount</th></tr>
|
||
{breakdown_rows}</table>
|
||
<p style="color: #888; font-size: 12px;">Holiday shifts are shaded and paid at the listed multiplier.</p>
|
||
|
||
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
|
||
</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={
|
||
"Subject": {"Data": f"Bonus Pay Summary — {week_label}"},
|
||
"Body": {"Html": {"Data": html_body}},
|
||
},
|
||
)
|
||
logger.info("Sent pay email to %s", recipients)
|
||
|
||
|
||
def handler(event, context):
|
||
now = datetime.now(EASTERN)
|
||
|
||
# DST guard — same pattern as the 3CX scheduler
|
||
force = event.get("force", False)
|
||
if not force and now.hour != 7:
|
||
logger.info(
|
||
"ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour
|
||
)
|
||
return {"skipped": True}
|
||
|
||
bot_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
||
channel_id = os.environ["SHIFT_CHANNEL"]
|
||
|
||
schedule = ShiftSchedule()
|
||
slack = WebClient(token=bot_token)
|
||
|
||
# --- 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)
|
||
|
||
pay_dm_user = os.environ.get("PAY_REPORT_USER")
|
||
|
||
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(
|
||
week_label, pay_record["breakdown"], pay_record["totals"]
|
||
)
|
||
|
||
# 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")
|
||
|
||
# Email pay summary to payroll. Isolated so a delivery failure (e.g.
|
||
# an SES permission/identity issue) can never abort the rest of the
|
||
# handler — the Slack schedule post below must still go out.
|
||
try:
|
||
_send_pay_email(week_label, pay_record)
|
||
except Exception:
|
||
logger.exception(
|
||
"Failed to send pay summary email to payroll for week of %s",
|
||
week_key,
|
||
)
|
||
|
||
# --- Delete previous week's schedule post ---
|
||
old_post = schedule.get_schedule_post(channel_id)
|
||
if old_post and old_post.get("message_ts"):
|
||
try:
|
||
slack.chat_delete(channel=channel_id, ts=old_post["message_ts"])
|
||
logger.info("Deleted previous schedule post %s", old_post["message_ts"])
|
||
except Exception:
|
||
logger.warning("Could not delete old schedule post", exc_info=True)
|
||
|
||
# --- 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)
|
||
|
||
end_date = this_monday + timedelta(days=13)
|
||
result = slack.chat_postMessage(
|
||
channel=channel_id,
|
||
blocks=blocks,
|
||
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
|
||
)
|
||
|
||
schedule.save_schedule_post(
|
||
channel_id, result["ts"], this_monday.strftime("%Y-%m-%d")
|
||
)
|
||
logger.info(
|
||
"Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]
|
||
)
|
||
return {
|
||
"posted": True,
|
||
"channel": channel_id,
|
||
"message_ts": result["ts"],
|
||
"pay_calculated": bool(pay_record["breakdown"]),
|
||
}
|