dodil/erp-brick-time-off
DODIL ERP brick: time off
src/brick_time_off/application/closures.py
"""Company closure days (TOF-13): the Christmas shutdown that comes out of everyone's allowance without anyone asking.
A closure day is ONE row per company and date (tof_closure_day, a plain INSERT: published once). What each PERSON pays is derived on read - the
balance deducts every closure day on which hr says the person is employed and works (a half or a whole, in the person's own unit), less the halves
an absence of theirs already holds, which is shown as covered by it. So a starter gets the shutdown with nobody recalculating, a leaver does not pay
for days after their last day, and no pass over a roster is needed. GAPS (AGENTS.md): the spec's per-person `closure:<company>:<date>:<person>` entry
is not written (derived instead), a published closure cannot be withdrawn, and HR's list of people a closure would push below zero needs a roster
hr does not offer: the balance shows `below_zero` for each person instead."""
from erp_platform import auth
from erp_platform.bricks import Refused
from brick_time_off.application import use_cases as hub
from brick_time_off.application.inputs import Code, DateText, Note
TOF = hub.TOF
CLOSURE_WRITE = "tof:closure:write"
def _view(row: dict) -> dict:
return {"company_code": row["company_code"], "closure_date": row["closure_date"].isoformat(), "name": row["name"], "leave_type_code": row["leave_type_code"],
"published_by": row["published_by"]}
@TOF.use_case(kind="command", route="POST /closure-days", permission=CLOSURE_WRITE, status=201)
def publish_time_off_closure_day(caller: dict, company_code: Code, closure_date: DateText, name: Note, leave_type_code: Code = "ANNUAL") -> dict:
"""Publish a day the company closes (HR): from then on it is taken from everyone's allowance of that leave type - each person who works that day,
in days or hours - unless they already hold the day in an absence of their own. A closure day is not in the past and is published once per company and
date (409 when it already is). The leave type must count against a policy. Nothing is booked per person: each balance derives it from hr's days."""
day = hub._date(closure_date, "closure_date")
if not name.strip():
raise Refused(422, "a closure day has a name, like 'Christmas shutdown'", gate="bounds")
with hub.repository() as repo, hub._decide():
if day < hub._today(repo):
raise hub._bounds(f"{day} is already past: a closure day is published before it", "publish it for a day to come")
lt = repo.catalog.leave_type(company_code, leave_type_code, day)
if lt is None or lt["counts_against"] in (None, "none"):
raise Refused(409, f"{leave_type_code} is not a leave type that counts against an allowance for {company_code}", gate="leave_type")
if repo.closures.get(company_code, day) is not None:
raise Refused(409, f"{day} is already a closure day for {company_code}", gate="closure_published")
me = hub._me(caller)
row = {"company_code": company_code, "closure_date": day, "name": name.strip(), "leave_type_code": leave_type_code,
"published_by": me.email or me.sub, "published_sub": me.sub, "published_at": hub._now()}
repo.closures.add(row, hub._now())
return _view(row)
@TOF.use_case(kind="query", route="GET /closure-days")
def list_time_off_closure_days(caller: dict, company_code: Code, year: int = 0) -> dict:
"""The company's closure days for a calendar year (default: this year), in date order - what everyone's allowance pays for before they ask."""
if not (auth.has_permission(caller, hub.READ_OWN) or auth.has_permission(caller, CLOSURE_WRITE)):
raise Refused(403, f"missing permission: {hub.READ_OWN}")
if year and not 2000 <= year <= 2100:
raise hub._bounds("year is between 2000 and 2100")
with hub.repository() as repo:
y = year or hub._today(repo).year
from datetime import date
return {"company_code": company_code, "year": y, "closure_days": [_view(r) for r in repo.closures.in_range(company_code, date(y, 1, 1), date(y, 12, 31))]}