mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +00:00
feat(meals): dual-read week keys and meal-to-Flex join (PLAT-134) (#182)
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
* feat(meals): dual-read week keys and meal-to-Flex join (PLAT-134) * style(meals): apply ruff format (PLAT-134) * docs(meals): clarify week-key dual-read is same-instant (PLAT-134)
This commit is contained in:
parent
60aa30bf12
commit
8489dc3986
5 changed files with 456 additions and 19 deletions
|
|
@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo
|
|||
from boto3.dynamodb.conditions import Attr, Key
|
||||
import boto3
|
||||
|
||||
from .week_keys import candidate_week_ids
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
_table = None
|
||||
|
||||
|
|
@ -49,9 +51,27 @@ def put_menu(week: str, menu_data: dict):
|
|||
)
|
||||
|
||||
|
||||
def get_menu(week: str) -> dict | None:
|
||||
resp = _get_table().get_item(Key={"PK": f"WEEK#{week}", "SK": "MENU"})
|
||||
return resp.get("Item")
|
||||
def _week_ids_to_read(week: str | datetime) -> tuple[str, ...]:
|
||||
"""Resolve week ids to try. Datetime dual-reads U-week then ISO; a string is used as-is."""
|
||||
if isinstance(week, datetime):
|
||||
return candidate_week_ids(week)
|
||||
return (week,)
|
||||
|
||||
|
||||
def _get_item_for_weeks(
|
||||
week: str, sk: str, when: datetime | None = None
|
||||
) -> dict | None:
|
||||
table = _get_table()
|
||||
for week_id in _week_ids_to_read(when if when is not None else week):
|
||||
resp = table.get_item(Key={"PK": f"WEEK#{week_id}", "SK": sk})
|
||||
item = resp.get("Item")
|
||||
if item:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def get_menu(week: str, when: datetime | None = None) -> dict | None:
|
||||
return _get_item_for_weeks(week, "MENU", when=when)
|
||||
|
||||
|
||||
def set_form_status(week: str, status: str):
|
||||
|
|
@ -62,8 +82,8 @@ def set_form_status(week: str, status: str):
|
|||
)
|
||||
|
||||
|
||||
def get_form_status(week: str) -> str:
|
||||
menu = get_menu(week)
|
||||
def get_form_status(week: str, when: datetime | None = None) -> str:
|
||||
menu = get_menu(week, when=when)
|
||||
if not menu:
|
||||
return "not_found"
|
||||
return menu.get("form_status", "closed")
|
||||
|
|
@ -91,19 +111,23 @@ def put_order(week: str, employee_slug: str, order_data: dict):
|
|||
)
|
||||
|
||||
|
||||
def get_orders(week: str) -> list[dict]:
|
||||
resp = _get_table().query(
|
||||
KeyConditionExpression=Key("PK").eq(f"WEEK#{week}")
|
||||
def get_orders(week: str, when: datetime | None = None) -> list[dict]:
|
||||
table = _get_table()
|
||||
for week_id in _week_ids_to_read(when if when is not None else week):
|
||||
resp = table.query(
|
||||
KeyConditionExpression=Key("PK").eq(f"WEEK#{week_id}")
|
||||
& Key("SK").begins_with("ORDER#"),
|
||||
)
|
||||
return resp.get("Items", [])
|
||||
items = resp.get("Items", [])
|
||||
if items:
|
||||
return items
|
||||
return []
|
||||
|
||||
|
||||
def get_order(week: str, employee_slug: str) -> dict | None:
|
||||
resp = _get_table().get_item(
|
||||
Key={"PK": f"WEEK#{week}", "SK": f"ORDER#{employee_slug}"}
|
||||
)
|
||||
return resp.get("Item")
|
||||
def get_order(
|
||||
week: str, employee_slug: str, when: datetime | None = None
|
||||
) -> dict | None:
|
||||
return _get_item_for_weeks(week, f"ORDER#{employee_slug}", when=when)
|
||||
|
||||
|
||||
def delete_order(week: str, employee_slug: str):
|
||||
|
|
@ -123,9 +147,8 @@ def put_summary(week: str, summary: dict):
|
|||
)
|
||||
|
||||
|
||||
def get_summary(week: str) -> dict | None:
|
||||
resp = _get_table().get_item(Key={"PK": f"WEEK#{week}", "SK": "SUMMARY"})
|
||||
return resp.get("Item")
|
||||
def get_summary(week: str, when: datetime | None = None) -> dict | None:
|
||||
return _get_item_for_weeks(week, "SUMMARY", when=when)
|
||||
|
||||
|
||||
def get_roster() -> list[dict]:
|
||||
|
|
|
|||
110
src/shared/shared/flex_join.py
Normal file
110
src/shared/shared/flex_join.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Dry-run join of meal orders to Flex worker emails.
|
||||
|
||||
Join meal ``employee_email`` to Flex worker emails: BUSINESS first, then
|
||||
personal. ``matched_on="business"`` wins if both would match.
|
||||
|
||||
A live Flex worker pull is a later dry-run the operator runs. This module
|
||||
accepts a worker-email fixture and does not call Paychex or POST
|
||||
checkcomponents. It does not map WEEK# onto Flex payPeriodId.
|
||||
|
||||
Thursday close dates are not meal weeks for a Flex pay period; that mapping
|
||||
is not encoded here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
MatchedOn = Literal["business", "personal"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JoinRow:
|
||||
employee_email: str
|
||||
worker_id: str
|
||||
matched_on: MatchedOn
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JoinReport:
|
||||
matched: tuple[JoinRow, ...]
|
||||
unmatched: tuple[str, ...]
|
||||
week: str | None = None
|
||||
|
||||
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
return text or None
|
||||
|
||||
|
||||
def _emails_from_worker(worker: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
business = _normalize_email(worker.get("business_email"))
|
||||
personal = _normalize_email(worker.get("personal_email"))
|
||||
communications = worker.get("communications") or []
|
||||
if isinstance(communications, list):
|
||||
for comm in communications:
|
||||
if not isinstance(comm, dict):
|
||||
continue
|
||||
ctype = str(comm.get("type") or "").strip().upper()
|
||||
email = _normalize_email(comm.get("uri"))
|
||||
if not email:
|
||||
continue
|
||||
if ctype == "BUSINESS" and business is None:
|
||||
business = email
|
||||
elif ctype in {"PERSONAL", "HOME"} and personal is None:
|
||||
personal = email
|
||||
return business, personal
|
||||
|
||||
|
||||
def join_orders_to_workers(
|
||||
orders: list[dict[str, Any]],
|
||||
workers: list[dict[str, Any]],
|
||||
*,
|
||||
week: str | None = None,
|
||||
) -> JoinReport:
|
||||
business_index: dict[str, str] = {}
|
||||
personal_index: dict[str, str] = {}
|
||||
for worker in workers:
|
||||
worker_id = worker.get("workerId")
|
||||
if worker_id is None:
|
||||
continue
|
||||
worker_id_str = str(worker_id)
|
||||
business, personal = _emails_from_worker(worker)
|
||||
if business is not None and business not in business_index:
|
||||
business_index[business] = worker_id_str
|
||||
if personal is not None and personal not in personal_index:
|
||||
personal_index[personal] = worker_id_str
|
||||
|
||||
matched: list[JoinRow] = []
|
||||
unmatched: list[str] = []
|
||||
for order in orders:
|
||||
raw_email = order.get("employee_email")
|
||||
original = "" if raw_email is None else str(raw_email)
|
||||
key = _normalize_email(raw_email)
|
||||
if key is not None and key in business_index:
|
||||
matched.append(
|
||||
JoinRow(
|
||||
employee_email=original,
|
||||
worker_id=business_index[key],
|
||||
matched_on="business",
|
||||
)
|
||||
)
|
||||
elif key is not None and key in personal_index:
|
||||
matched.append(
|
||||
JoinRow(
|
||||
employee_email=original,
|
||||
worker_id=personal_index[key],
|
||||
matched_on="personal",
|
||||
)
|
||||
)
|
||||
else:
|
||||
unmatched.append(original)
|
||||
|
||||
return JoinReport(
|
||||
matched=tuple(matched),
|
||||
unmatched=tuple(unmatched),
|
||||
week=week,
|
||||
)
|
||||
41
src/shared/shared/week_keys.py
Normal file
41
src/shared/shared/week_keys.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Sunday-week and ISO week id helpers for Dynamo WEEK# dual-read.
|
||||
|
||||
Existing meal PKs were written with strftime("%Y-W%U") (Sunday week, can be
|
||||
W00). ISO week is "%G-W%V". Dual-read tries U-week first, then ISO, without a
|
||||
migration. Writes stay on %Y-W%U.
|
||||
|
||||
This is not a mapping from WEEK# onto Flex payPeriodId.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
WEEK_ID_RE = re.compile(r"^\d{4}-W\d{2}$")
|
||||
|
||||
|
||||
def week_id_ustrftime(dt: datetime) -> str:
|
||||
return dt.strftime("%Y-W%U")
|
||||
|
||||
|
||||
def week_id_iso(dt: datetime) -> str:
|
||||
return dt.strftime("%G-W%V")
|
||||
|
||||
|
||||
def candidate_week_ids(dt: datetime) -> tuple[str, ...]:
|
||||
"""Return U-week then ISO encodings of the same instant.
|
||||
|
||||
Dual-read does not advance the calendar week. ``%Y-W%U`` and ``%G-W%V``
|
||||
are two strftime renderings of ``dt``. Example: 2026-01-01 encodes as
|
||||
("2026-W00", "2026-W01"), not as "this week" then "next week".
|
||||
"""
|
||||
u_week = week_id_ustrftime(dt)
|
||||
iso_week = week_id_iso(dt)
|
||||
if u_week == iso_week:
|
||||
return (u_week,)
|
||||
return (u_week, iso_week)
|
||||
|
||||
|
||||
def candidate_week_pks(dt: datetime) -> tuple[str, ...]:
|
||||
return tuple(f"WEEK#{week_id}" for week_id in candidate_week_ids(dt))
|
||||
161
tests/test_flex_join.py
Normal file
161
tests/test_flex_join.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for meal-order to Flex worker email join (fixture-driven, no HTTP)."""
|
||||
|
||||
import inspect
|
||||
|
||||
from shared.flex_join import JoinReport, JoinRow, join_orders_to_workers
|
||||
|
||||
|
||||
def test_business_email_match():
|
||||
orders = [
|
||||
{"employee_email": "pat@seahaven.com", "employee_name": "Pat", "total": 12}
|
||||
]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W1",
|
||||
"business_email": "pat@seahaven.com",
|
||||
"personal_email": "pat@gmail.com",
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers, week="2026-W34")
|
||||
|
||||
assert report == JoinReport(
|
||||
matched=(
|
||||
JoinRow(
|
||||
employee_email="pat@seahaven.com",
|
||||
worker_id="W1",
|
||||
matched_on="business",
|
||||
),
|
||||
),
|
||||
unmatched=(),
|
||||
week="2026-W34",
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_to_personal_when_business_missing():
|
||||
orders = [{"employee_email": "pat@gmail.com"}]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W1",
|
||||
"business_email": "",
|
||||
"personal_email": " PAT@gmail.com ",
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert report.matched == (
|
||||
JoinRow(
|
||||
employee_email="pat@gmail.com",
|
||||
worker_id="W1",
|
||||
matched_on="personal",
|
||||
),
|
||||
)
|
||||
assert report.unmatched == ()
|
||||
|
||||
|
||||
def test_fallback_to_personal_when_business_mismatches():
|
||||
orders = [{"employee_email": "alex@gmail.com"}]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W2",
|
||||
"business_email": "alex@seahaven.com",
|
||||
"personal_email": "alex@gmail.com",
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert report.matched[0].matched_on == "personal"
|
||||
assert report.matched[0].worker_id == "W2"
|
||||
assert report.unmatched == ()
|
||||
|
||||
|
||||
def test_unmatched_leftover():
|
||||
orders = [
|
||||
{"employee_email": "pat@seahaven.com"},
|
||||
{"employee_email": "unknown@example.com"},
|
||||
]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W1",
|
||||
"business_email": "pat@seahaven.com",
|
||||
"personal_email": "pat@gmail.com",
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert len(report.matched) == 1
|
||||
assert report.unmatched == ("unknown@example.com",)
|
||||
|
||||
|
||||
def test_business_wins_over_personal_if_both_present():
|
||||
orders = [{"employee_email": "sam@seahaven.com"}]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W-personal",
|
||||
"business_email": "other@seahaven.com",
|
||||
"personal_email": "sam@seahaven.com",
|
||||
},
|
||||
{
|
||||
"workerId": "W-business",
|
||||
"business_email": "sam@seahaven.com",
|
||||
"personal_email": "sam@gmail.com",
|
||||
},
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert report.matched == (
|
||||
JoinRow(
|
||||
employee_email="sam@seahaven.com",
|
||||
worker_id="W-business",
|
||||
matched_on="business",
|
||||
),
|
||||
)
|
||||
assert report.unmatched == ()
|
||||
|
||||
|
||||
def test_same_worker_business_and_personal_same_email_is_business():
|
||||
orders = [{"employee_email": "lee@seahaven.com"}]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W3",
|
||||
"business_email": "lee@seahaven.com",
|
||||
"personal_email": "lee@seahaven.com",
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert report.matched[0].matched_on == "business"
|
||||
assert report.matched[0].worker_id == "W3"
|
||||
|
||||
|
||||
def test_nested_communications_used_when_flat_keys_missing():
|
||||
orders = [{"employee_email": "pat@seahaven.com"}]
|
||||
workers = [
|
||||
{
|
||||
"workerId": "W1",
|
||||
"communications": [
|
||||
{"type": "BUSINESS", "uri": "pat@seahaven.com"},
|
||||
{"type": "PERSONAL", "uri": "pat@gmail.com"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
report = join_orders_to_workers(orders, workers)
|
||||
|
||||
assert report.matched[0].matched_on == "business"
|
||||
assert report.matched[0].worker_id == "W1"
|
||||
|
||||
|
||||
def test_flex_join_has_no_paychex_http():
|
||||
import shared.flex_join as flex_join
|
||||
|
||||
source = inspect.getsource(flex_join)
|
||||
assert "urllib" not in source
|
||||
assert "api.paychex.com" not in source
|
||||
assert "requests" not in source
|
||||
assert "http.client" not in source
|
||||
102
tests/test_week_keys.py
Normal file
102
tests/test_week_keys.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Tests for Sunday-week / ISO dual-read week keys and GET helpers."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from shared import db
|
||||
from shared.week_keys import (
|
||||
WEEK_ID_RE,
|
||||
candidate_week_ids,
|
||||
candidate_week_pks,
|
||||
week_id_iso,
|
||||
week_id_ustrftime,
|
||||
)
|
||||
|
||||
MISMATCH_DT = datetime(2026, 1, 1)
|
||||
MATCH_DT = datetime(2026, 1, 4)
|
||||
|
||||
|
||||
def test_ustrftime_and_iso_differ_on_new_year():
|
||||
assert week_id_ustrftime(MISMATCH_DT) == "2026-W00"
|
||||
assert week_id_iso(MISMATCH_DT) == "2026-W01"
|
||||
assert WEEK_ID_RE.match("2026-W00")
|
||||
assert WEEK_ID_RE.match("2026-W01")
|
||||
|
||||
|
||||
def test_candidate_week_ids_u_week_first_then_iso():
|
||||
# 2026-01-01: same instant encoded two ways, not a next-week lookup.
|
||||
assert candidate_week_ids(MISMATCH_DT) == ("2026-W00", "2026-W01")
|
||||
|
||||
|
||||
def test_candidate_week_ids_unique_when_formats_match():
|
||||
ids = candidate_week_ids(MATCH_DT)
|
||||
assert week_id_ustrftime(MATCH_DT) == week_id_iso(MATCH_DT) == "2026-W01"
|
||||
assert ids == ("2026-W01",)
|
||||
assert len(ids) == 1
|
||||
|
||||
|
||||
def test_candidate_week_pks_prefixed():
|
||||
assert candidate_week_pks(MISMATCH_DT) == ("WEEK#2026-W00", "WEEK#2026-W01")
|
||||
assert candidate_week_pks(MATCH_DT) == ("WEEK#2026-W01",)
|
||||
|
||||
|
||||
def _table_with_get_items(items_by_pk: dict[str, dict]):
|
||||
table = MagicMock()
|
||||
|
||||
def get_item(Key):
|
||||
item = items_by_pk.get(Key["PK"])
|
||||
if item is None:
|
||||
return {}
|
||||
if item.get("SK") != Key["SK"]:
|
||||
return {}
|
||||
return {"Item": item}
|
||||
|
||||
table.get_item.side_effect = get_item
|
||||
return table
|
||||
|
||||
|
||||
def test_get_menu_when_falls_back_to_iso():
|
||||
iso_item = {"PK": "WEEK#2026-W01", "SK": "MENU", "source": "iso"}
|
||||
table = _table_with_get_items({"WEEK#2026-W01": iso_item})
|
||||
|
||||
with patch.object(db, "_get_table", return_value=table):
|
||||
result = db.get_menu("2026-W00", when=MISMATCH_DT)
|
||||
|
||||
assert result == iso_item
|
||||
pks = [call.kwargs["Key"]["PK"] for call in table.get_item.call_args_list]
|
||||
assert pks == ["WEEK#2026-W00", "WEEK#2026-W01"]
|
||||
|
||||
|
||||
def test_get_menu_when_prefers_u_week_if_both_exist():
|
||||
u_item = {"PK": "WEEK#2026-W00", "SK": "MENU", "source": "u"}
|
||||
iso_item = {"PK": "WEEK#2026-W01", "SK": "MENU", "source": "iso"}
|
||||
table = _table_with_get_items({"WEEK#2026-W00": u_item, "WEEK#2026-W01": iso_item})
|
||||
|
||||
with patch.object(db, "_get_table", return_value=table):
|
||||
result = db.get_menu("2026-W00", when=MISMATCH_DT)
|
||||
|
||||
assert result == u_item
|
||||
assert table.get_item.call_count == 1
|
||||
assert table.get_item.call_args.kwargs["Key"]["PK"] == "WEEK#2026-W00"
|
||||
|
||||
|
||||
def test_get_menu_without_when_uses_week_argument_as_single_key():
|
||||
table = _table_with_get_items({})
|
||||
|
||||
with patch.object(db, "_get_table", return_value=table):
|
||||
result = db.get_menu("2026-W34")
|
||||
|
||||
assert result is None
|
||||
table.get_item.assert_called_once_with(Key={"PK": "WEEK#2026-W34", "SK": "MENU"})
|
||||
|
||||
|
||||
def test_get_orders_when_falls_back_to_iso():
|
||||
iso_orders = [{"PK": "WEEK#2026-W01", "SK": "ORDER#pat"}]
|
||||
table = MagicMock()
|
||||
table.query.side_effect = [{"Items": []}, {"Items": iso_orders}]
|
||||
|
||||
with patch.object(db, "_get_table", return_value=table):
|
||||
result = db.get_orders("2026-W00", when=MISMATCH_DT)
|
||||
|
||||
assert result == iso_orders
|
||||
assert table.query.call_count == 2
|
||||
Loading…
Add table
Reference in a new issue