dodil/erp-brick-timesheets
DODIL ERP brick: timesheets
tests/test_stories.py
"""One test per story of module.yaml (slice A), written from its Given/When/Then. The names carry the story id."""
from datetime import date
from decimal import Decimal
from conftest import (
APPROVAL_DAY,
EMP,
EMPLOYEE,
EMPLOYEE2,
FINANCE,
FINANCE2,
LEAD,
LEADER,
MANAGER,
MANAGER2,
MGR,
OWNER,
P1,
P2,
PAYROLL,
SHEET,
SYSTEM,
TIMEOFF,
api,
approve,
approved_week,
hours,
line,
must,
on_day,
open_week,
person,
refused,
sheet,
story_week,
submit,
)
from brick_timesheets.application import use_cases
ITEM = "quote:Q-2026-0042@v2|2|1"
SHEET2 = "4425:2026-W47"
def by_kind(s, kind):
return [ln for ln in s["lines"] if ln["kind"] == kind]
def cover(sheet_id, who, project=P1, activity="DEV", days=("16", "17", "18", "19", "20"), **kw):
"""A fully covered week: 7.50 on a project every working day."""
n = line(sheet_id, who, project_id=project, activity_code=activity, **kw)["lines"][-1]["line_no"]
hours(sheet_id, n, {f"2026-11-{d}": "7.50" for d in days}, who)
return n
# ── TS-01 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_01_the_week_opens_with_contracted_hours_and_the_approved_holiday_already_in_it(nb):
s = open_week()
assert s["sheet_id"] == SHEET and s["revision"] == 1 and s["status"] == "open" and s["week_start"] == "2026-11-16"
assert s["expected_hours"] == "37.50" and s["home_cost_centre"] == "CONS-LON"
(leave,) = by_kind(s, "absence")
assert (leave["activity_code"], leave["absence_ref"], leave["hours"]) == ("ANNUAL", "UK01-TOF-2026-00405", "7.50")
assert leave["entries"] == [{"work_date": "2026-11-20", "hours": "7.50", "note": None}]
assert [d["booked_hours"] for d in s["days"]] == ["0.00", "0.00", "0.00", "0.00", "7.50", "0.00", "0.00"]
assert s["booked_hours"] == "7.50"
assert open_week() == s # opened once: a second open answers the same sheet
assert len(must(api("GET", "/sheets", EMPLOYEE))["timesheets"]) == 1
def test_ts_01_a_bank_holiday_expects_nothing_and_a_week_outside_the_employment_does_not_open(nb):
nb.holidays["2026-11-17"] = "Founders Day"
s = open_week()
assert s["expected_hours"] == "30.00" and s["days"][1]["expected_hours"] == "0.00"
nb.starts_on[4425] = "2026-11-30"
refused(api("POST", "/sheets", EMPLOYEE2, {"week": WEEK_}), 409, "not employed")
nb.starts_on.pop(4425)
nb.left_on[4425] = "2026-11-13"
refused(api("POST", "/sheets", EMPLOYEE2, {"week": WEEK_}), 409, "not employed")
WEEK_ = "2026-W47"
def test_ts_01_a_person_hr_does_not_know_cannot_record_time(nb):
refused(api("POST", "/sheets", person("stranger@uk.example", ["ts:sheet:write_own"]), {"week": WEEK_}), 409, "hr holds no person")
# ── TS-02 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_02_hours_book_to_project_items_and_internal_cost_centres_and_billable_is_set_for_me(nb):
open_week()
a, b, c = story_week()
s = sheet()
p1, admin, p2 = (s["lines"][i] for i in (1, 2, 3))
assert (p1["billable"], p1["bill_via"], p1["hours"]) == (True, "sales_order", "19.00")
assert (p2["billable"], p2["bill_via"], p2["hours"]) == (True, "ar", "7.50")
assert (admin["billable"], admin["kind"], admin["hours"]) == (False, "internal", "3.50")
assert s["project_hours"] == {P1: "19.00", P2: "7.50"}
assert (s["worked_hours"], s["absence_hours"], s["booked_hours"], s["expected_hours"]) == ("30.00", "7.50", "37.50", "37.50")
def test_ts_02_a_line_is_never_billable_on_a_non_billable_project_but_may_be_unticked_on_a_billable_one(nb):
open_week()
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": "pj:manual:INTERNAL-1", "billable": True}), 409, "never billable")
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": "pj:manual:FIXED-1", "billable": True}), 409, "fixed price")
quiet = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": P1, "billable": False}), 201)["lines"][-1]
assert quiet["billable"] is False and quiet["bill_via"] == "none"
free = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "project_id": P1}), 201)["lines"][-1]
assert free["billable"] is False # ADMIN is not billable by default
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "cost_centre": "CONS-LON", "billable": True}), 409, "never billable")
def test_ts_02_hours_are_bounded_in_quarter_hours_and_a_day_never_exceeds_24(nb):
open_week()
n = line(SHEET, project_id=P1)["lines"][-1]["line_no"]
path = f"/sheets/{SHEET}/lines/{n}/entries"
for bad in ("-1", "24.25", "7.10", "7.12345", "abc", "NaN", "1e3", ""):
body = refused(api("POST", path, EMPLOYEE, {"entries": [{"work_date": "2026-11-16", "hours": bad}]}), 422)
assert body.get("gate") in ("bounds", None)
refused(api("POST", path, EMPLOYEE, {"entries": [{"work_date": "2026-11-23", "hours": "1"}]}), 422, "outside week") # not this week
refused(api("POST", path, EMPLOYEE, {"entries": [{"work_date": "2026-11-16", "hours": "1"}, {"work_date": "2026-11-16", "hours": "2"}]}), 422, "twice")
refused(api("POST", path, EMPLOYEE, {"entries": [{"work_date": "garbage", "hours": "1"}]}), 422)
m = line(SHEET, project_id=P2, activity_code="MEETING")["lines"][-1]["line_no"]
hours(SHEET, n, {"2026-11-16": "20.00"})
refused(api("POST", f"/sheets/{SHEET}/lines/{m}/entries", EMPLOYEE, {"entries": [{"work_date": "2026-11-16", "hours": "4.25"}]}), 422, "at most 24")
assert sheet()["worked_hours"] == "20.00"
hours(SHEET, n, {"2026-11-16": "0"}) # zero clears the day
assert sheet()["worked_hours"] == "0.00"
def test_ts_02_a_project_that_is_not_bookable_on_the_day_is_refused_at_submission_naming_it(nb):
open_week()
cover(SHEET, EMPLOYEE, days=("16", "17", "18", "19"), item_id=ITEM)
nb.projects[P1]["items"][0]["state"] = "done"
body = refused(api("POST", f"/sheets/{SHEET}/submit", EMPLOYEE), 409, "item " + ITEM)
assert body["gate"] == "bookable"
nb.projects[P1]["items"][0]["state"] = "open"
nb.projects[P1]["state"] = "closed"
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": P1}), 409, "closed")
assert sheet()["status"] == "open"
# ── TS-03 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_03_a_covered_week_submits_and_its_lines_freeze_with_the_approver_fixed(nb):
open_week()
story_week()
s = submit()
assert s["status"] == "submitted" and s["approver"] == {"person_id": "2280", "rerouted_from": None} and s["approval_route"] == "manager"
assert all(g["met"] for g in s["readiness"])
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": P1}), 409, "frozen")
refused(api("POST", f"/sheets/{SHEET}/lines/2/entries", EMPLOYEE, {"entries": [{"work_date": "2026-11-16", "hours": "1"}]}), 409, "frozen")
nb.managers[4410][0] = nb.as_manager(1904, "mgr1904@uk.example") # a later reorganisation does not move it
assert sheet()["approver"]["person_id"] == "2280"
def test_ts_03_an_uncovered_day_is_named_and_the_week_stays_open(nb):
open_week(EMPLOYEE2)
cover(SHEET2, EMPLOYEE2, days=("16", "17", "19", "20"), project=P1) # Wednesday the 18th is empty
body = refused(api("POST", f"/sheets/{SHEET2}/submit", EMPLOYEE2), 409, "hours_covered")
assert body["gate"] == "hours_covered" and "2026-11-18: 0.00 of 7.50" in body["reason"]
assert [g["met"] for g in body["gates"]][:1] == [False] and sheet(SHEET2, EMPLOYEE2)["status"] == "open"
def test_ts_03_a_long_day_needs_a_note_and_warn_mode_lets_an_uncovered_day_through(nb):
open_week(EMPLOYEE2)
n = cover(SHEET2, EMPLOYEE2, days=("17", "18", "19", "20"), project=P1)
hours(SHEET2, n, {"2026-11-16": "12.50"}, EMPLOYEE2)
refused(api("POST", f"/sheets/{SHEET2}/submit", EMPLOYEE2), 409, "has no note")
hours(SHEET2, n, {"2026-11-16": ("12.50", "release night")}, EMPLOYEE2)
assert submit(SHEET2, EMPLOYEE2)["status"] == "submitted"
open_week(person("emp3020@uk.example", ["ts:sheet:write_own", "ts:sheet:read_own", "ts:sheet:submit"], sub=LEAD))
with use_cases.repository() as repo: # missing_hours_mode warn: a product company records time on projects only
repo.settings.put("missing_hours_mode", "warn", date(2026, 1, 1), "owner", use_cases._now())
who = person(LEAD, ["ts:sheet:write_own", "ts:sheet:read_own", "ts:sheet:submit"])
nb.minutes[3020] = 450
assert submit("3020:2026-W47", who)["status"] == "submitted"
# ── TS-04 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_04_the_manager_approves_the_week_and_everything_downstream_starts_from_it(nb):
s = approved_week()
assert s["status"] == "approved" and s["approved_by"] == "mgr2280@uk.example" and s["approved_on"] == "2026-11-23" and s["revision"] == 1
assert [o["status"] for o in s["outbox"]] == ["sent", "sent"]
assert len(nb.journals) == 1 and list(nb.deliveries) == ["ts:4410:2026-W47:r1:2"]
refused(api("POST", f"/sheets/{SHEET}/approve", MANAGER), 409, "approved") # not twice
def test_ts_04_a_managers_own_week_goes_to_their_manager_and_nobody_approves_their_own_week(nb):
nb.minutes[2280] = 0
me = person(MGR, ["ts:sheet:read_own", "ts:sheet:write_own", "ts:sheet:submit", "ts:sheet:read_team", "ts:sheet:approve"])
sid = "2280:2026-W47"
open_week(me)
s = submit(sid, me)
assert s["approver"]["person_id"] == "1904"
refused(api("POST", f"/sheets/{sid}/approve", me), 409, "own week") # the gate is second_person
body = api("POST", f"/sheets/{sid}/approve", me)[1]
assert body["gate"] == "second_person"
twin = person("mgr2280.alias@uk.example", ["ts:sheet:read_team", "ts:sheet:approve", "ts:sheet:read_own"], sub=MGR) # a second login, the same sub
assert api("POST", f"/sheets/{sid}/approve", twin)[1]["gate"] == "second_person"
with on_day(APPROVAL_DAY):
assert must(api("POST", f"/sheets/{sid}/approve", MANAGER2))["status"] == "approved"
def test_ts_04_only_the_fixed_approver_approves_and_a_stranger_cannot_even_see_the_week(nb):
open_week()
story_week()
submit()
other = person("mgr9999@uk.example", ["ts:sheet:read_team", "ts:sheet:approve", "ts:sheet:read_own"])
refused(api("POST", f"/sheets/{SHEET}/approve", other), 404) # not their team: not found, never forbidden
refused(api("POST", f"/sheets/{SHEET}/approve", EMPLOYEE2), 404)
refused(api("POST", f"/sheets/{SHEET}/approve", EMPLOYEE), 403, "ts:sheet:approve")
refused(api("POST", f"/sheets/{SHEET}/approve", FINANCE), 403, "ts:sheet:approve")
assert approve(SHEET, MANAGER)[0] == 200
# ── TS-05 ────────────────────────────────────────────────────────────────────────────────────
def lead_route():
with use_cases.repository() as repo:
repo.settings.put("approval_route", "project_lead_then_manager", date(2026, 1, 1), "owner", use_cases._now())
def test_ts_05_a_project_lead_approves_their_projects_lines_first_and_sees_only_those(nb):
lead_route()
open_week()
story_week()
s = submit()
assert s["approval_route"] == "project_lead_then_manager"
body = refused(api("POST", f"/sheets/{SHEET}/approve", MANAGER), 409, "wait for their project lead")
assert body["gate"] == "project_lines_approved"
seen = sheet(SHEET, LEADER)
assert seen["restricted"] and {ln["project_id"] for ln in seen["lines"]} == {P1, P2} and "days" not in seen and "expected_hours" not in seen
got = must(api("POST", f"/sheets/{SHEET}/approve-project-lines", LEADER))
assert {ln["line_no"] for ln in got["lines"]} == {2, 4}
refused(api("POST", f"/sheets/{SHEET}/approve-project-lines", LEADER), 409, "already approved")
assert approve(SHEET, MANAGER)[0] == 200
assert [a["line_no"] for a in sheet(SHEET, FINANCE)["line_approvals"]] == [2, 4]
def test_ts_05_the_leads_own_lines_go_to_their_manager_and_a_second_login_changes_nothing(nb):
lead_route()
nb.minutes[3020] = 0
me = person(LEAD, ["ts:sheet:read_own", "ts:sheet:write_own", "ts:sheet:submit", "ts:line:approve_project"])
sid = "3020:2026-W47"
open_week(me)
n = line(sid, me, project_id=P1)["lines"][-1]["line_no"]
hours(sid, n, {"2026-11-16": "6.00"}, me)
assert submit(sid, me)["approver"]["person_id"] == "2280" # the lead's manager, never the lead
refused(api("POST", f"/sheets/{sid}/approve-project-lines", me), 409, "own week")
twin = person("lead3020.alias@uk.example", ["ts:line:approve_project", "ts:sheet:read_own"], sub=LEAD)
assert api("POST", f"/sheets/{sid}/approve-project-lines", twin)[1]["gate"] == "second_person"
assert approve(sid, MANAGER)[0] == 200 # no project line waits: the lead's own lines need no lead
def test_ts_05_a_lead_returning_a_line_returns_the_whole_week_and_a_lead_of_other_projects_sees_nothing(nb):
lead_route()
open_week()
story_week()
submit()
stranger = person("lead7777@uk.example", ["ts:line:approve_project", "ts:sheet:read_own"])
nb.employee(7777, "lead7777@uk.example")
refused(api("GET", f"/sheets/{SHEET}", stranger), 404)
refused(api("POST", f"/sheets/{SHEET}/approve-project-lines", stranger), 404)
got = must(api("POST", f"/sheets/{SHEET}/return", LEADER, {"reason": "Wednesday is not P1"}))
assert got["revision"] == 2 and got["status"] == "open"
# ── TS-06 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_06_a_returned_week_continues_as_a_new_revision_and_nothing_moves_back(nb):
open_week(EMPLOYEE2)
n = cover(SHEET2, EMPLOYEE2, days=("17", "18", "19", "20"), project=P1)
hours(SHEET2, n, {"2026-11-16": ("7.50", "two days of work")}, EMPLOYEE2)
submit(SHEET2, EMPLOYEE2)
got = must(api("POST", f"/sheets/{SHEET2}/return", MANAGER, {"reason": "Monday looks like two days of work, please split"}))
assert got["revision"] == 2 and got["status"] == "open" and got["approver"] is None and got["line_approvals"] == []
assert got["revisions"] == [{"revision": 1, "status": "returned"}, {"revision": 2, "status": "open"}]
r1 = sheet(SHEET2, EMPLOYEE2, revision=1)
assert r1["status"] == "returned" and r1["reason"].startswith("Monday looks like") and len(r1["lines"]) == len(got["lines"])
p2 = line(SHEET2, EMPLOYEE2, project_id=P2, activity_code="MEETING")["lines"][-1]["line_no"]
hours(SHEET2, n, {"2026-11-16": "5.00"}, EMPLOYEE2)
hours(SHEET2, p2, {"2026-11-16": "2.50"}, EMPLOYEE2)
assert submit(SHEET2, EMPLOYEE2)["revision"] == 2
assert approve(SHEET2, MANAGER)[0] == 200
s = sheet(SHEET2, EMPLOYEE2)
assert s["status"] == "approved" and s["revision"] == 2 and sheet(SHEET2, EMPLOYEE2, revision=1)["status"] == "returned"
refused(api("POST", f"/sheets/{SHEET2}/return", MANAGER, {"reason": "too late"}), 409, "approved") # approved never goes back to returned
def test_ts_06_a_return_needs_a_reason_and_a_sheet_that_is_not_submitted_is_not_returned(nb):
open_week()
story_week()
refused(api("POST", f"/sheets/{SHEET}/return", MANAGER, {"reason": "x"}), 404) # an open week is not the approver's to see yet
submit()
assert api("POST", f"/sheets/{SHEET}/return", MANAGER, {"reason": " "})[0] == 422
refused(api("POST", f"/sheets/{SHEET}/return", EMPLOYEE, {"reason": "x"}), 409, "own week")
# ── TS-07 ────────────────────────────────────────────────────────────────────────────────────
def correct_week():
"""TS-07: 2.00 of Wednesday were on P2, not P1. Finance reopens, the employee edits revision 2, the manager approves it."""
approved_week()
must(api("POST", f"/sheets/{SHEET}/reopen", FINANCE, {"reason": "2h Wednesday on P2"}))
hours(SHEET, 2, {"2026-11-18": "2.00"})
hours(SHEET, 4, {"2026-11-18": "2.00", "2026-11-19": "7.50"})
submit()
return approve()
def test_ts_07_a_correction_is_a_new_revision_and_cost_and_deliveries_move_by_the_difference(nb):
r = correct_week()
assert r[0] == 200 and r[1]["revision"] == 2 and r[1]["status"] == "approved"
assert sheet(SHEET, FINANCE, revision=1)["status"] == "superseded" and sheet(SHEET, FINANCE)["revisions"] == [{"revision": 1, "status": "superseded"},
{"revision": 2, "status": "approved"}]
journals = sorted(nb.journals.values(), key=lambda j: j["event"])
assert [j["event"] for j in journals] == ["approve", "approve", "reverse"] and len(nb.journals) == 3
(rev,) = [j for j in journals if j["event"] == "reverse"]
assert rev["source_document"] == "ts:4410:2026-W47:r1" # the journal it reverses
assert {(x["cost_centre"], x.get("credit"), x.get("debit")) for x in rev["lines"]} == {("DELIV-1", "988.00", None), ("DELIV-2", "390.00", None), ("CONS-LON", None, "1378.00")}
(new,) = [j for j in journals if j["event"] == "approve" and j["source_document"] == "ts:4410:2026-W47:r2"]
assert {(x["cost_centre"], x.get("debit")) for x in new["lines"] if "debit" in x} == {("DELIV-1", "884.00"), ("DELIV-2", "494.00")} # 17.00 and 9.50 x 52.0000
assert [x["credit"] for x in new["lines"] if "credit" in x] == ["1378.00"]
assert nb.deliveries["ts:4410:2026-W47:r2:2"]["quantity"] == "-2.0000" and nb.deliveries["ts:4410:2026-W47:r2:2"]["corrects_ref"] == "ts:4410:2026-W47:r1:2"
assert nb.orders_book["quote:Q-2026-0042@v2"][2]["delivered"] == Decimal("57") # 40 + 19 - 2
def test_ts_07_the_effects_stay_until_the_correction_is_approved_and_payroll_reads_the_latest_revision(nb):
approved_week()
must(api("POST", f"/sheets/{SHEET}/reopen", FINANCE, {"reason": "2h Wednesday on P2"}))
assert len(nb.journals) == 1 and len(nb.deliveries) == 1 # nothing moved yet
rows = must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}))["rows"]
assert {r["revision"] for r in rows} == {1} # revision 1 still stands while 2 is in correction
hours(SHEET, 2, {"2026-11-18": "2.00"})
hours(SHEET, 4, {"2026-11-18": "2.00", "2026-11-19": "7.50"})
submit()
approve()
rows = must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}))["rows"]
assert {r["revision"] for r in rows} == {2}
with_changes = must(api("GET", "/approved-hours", TIMEOFF, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22", "changed_since": "2026-11-01T00:00:00Z"}))
assert {(r["revision"], r["status"]) for r in with_changes["rows"]} == {(1, "superseded"), (2, "approved")}
def test_ts_07_nobody_reopens_their_own_week_or_one_they_approved_and_only_finance_reopens(nb):
approved_week()
refused(api("POST", f"/sheets/{SHEET}/reopen", EMPLOYEE, {"reason": "x"}), 403, "ts:sheet:reopen")
refused(api("POST", f"/sheets/{SHEET}/reopen", MANAGER, {"reason": "x"}), 403, "ts:sheet:reopen")
assert api("POST", f"/sheets/{SHEET}/reopen", FINANCE, {"reason": " "})[0] == 422
mine = person(EMP, ["ts:sheet:reopen", "ts:sheet:read_all"])
refused(api("POST", f"/sheets/{SHEET}/reopen", mine, {"reason": "x"}), 409, "own week")
approver_as_finance = person("mgr2280.finance@uk.example", ["ts:sheet:reopen", "ts:sheet:read_all"], sub="mgr2280@uk.example")
refused(api("POST", f"/sheets/{SHEET}/reopen", approver_as_finance, {"reason": "x"}), 409, "You approved this week")
assert must(api("POST", f"/sheets/{SHEET}/reopen", FINANCE2, {"reason": "ok"}))["revision"] == 2
refused(api("POST", f"/sheets/{SHEET}/reopen", FINANCE, {"reason": "again"}), 409, "open") # revision 2 is open, not approved
# ── TS-08 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_08_cost_rates_are_dated_rows_and_a_line_costs_at_the_rate_in_force_on_its_work_date(nb):
got = must(api("POST", "/cost-rates", FINANCE, {"company_code": "UK01", "rate_group": "senior", "rate_amount": "55.0000", "currency": "GBP",
"valid_from": "2026-11-18"}), 201)
assert got["rate_group"] == "SENIOR"
# the rate group of the first row of a key may start any day; a change must start after today ...
refused(api("POST", "/cost-rates", FINANCE, {"company_code": "UK01", "rate_group": "SENIOR", "rate_amount": "60.0000", "currency": "GBP", "valid_from": "2026-11-20"}),
422, "after today")
must(api("POST", "/person-rate-groups", FINANCE, {"person_id": "4425", "rate_group": "PRINCIPAL", "valid_from": "2026-12-01"}), 201)
refused(api("POST", "/person-rate-groups", FINANCE, {"person_id": "4425", "rate_group": "PRINCIPAL", "valid_from": "2026-11-01"}), 422, "after today")
# ... so the future rows exist, the rows of the past are never edited, and the week costs by the day: Mon 16 at 52, Wed 18 at 55
nb.projects[P2]["products"] = [{"rate_group": "SENIOR", "product_id": "CONSULT-SR", "so_line_no": None}]
open_week()
n = line(SHEET, project_id=P2, activity_code="DEV")["lines"][-1]["line_no"]
hours(SHEET, n, {"2026-11-16": "2.00", "2026-11-18": "2.00"})
for d in ("17", "19"):
m = line(SHEET, project_id=P2, activity_code="MEETING")["lines"][-1]["line_no"]
hours(SHEET, m, {f"2026-11-{d}": "7.50"})
k = line(SHEET, project_id=P2, activity_code="ADMIN")["lines"][-1]["line_no"]
hours(SHEET, k, {"2026-11-16": "5.50", "2026-11-18": "5.50"})
submit()
approve()
(j,) = nb.journals.values()
deliv2 = [x for x in j["lines"] if x.get("cost_centre") == "DELIV-2"][0]
# line n: 2x52 + 2x55 = 214; the 7.50 lines 7.5x52 = 390 (Tue), 7.5x55 = 412.50 (Thu); ADMIN 5.5x52 + 5.5x55 = 588.50 -> 1605.00
assert deliv2["debit"] == "1605.00"
assert [x for x in j["lines"] if "credit" in x][0]["credit"] == "1605.00"
def test_ts_08_a_person_without_a_rate_group_cannot_submit_time_that_costs(nb):
nb.employee(5001, "emp5001@uk.example", manager=nb.as_manager(2280, "mgr2280@uk.example"))
who = person("emp5001@uk.example", ["ts:sheet:write_own", "ts:sheet:read_own", "ts:sheet:submit"])
sid = "5001:2026-W47"
open_week(who)
cover(sid, who, days=("16", "17", "18", "19", "20"))
body = refused(api("POST", f"/sheets/{sid}/submit", who), 409, "no cost rate")
assert body["gate"] == "rate_group"
with use_cases.repository() as repo:
repo.rates.put_person(5001, "JUNIOR", date(2026, 1, 1), "fin", use_cases._now())
assert submit(sid, who)["status"] == "submitted"
def test_ts_08_cost_rates_are_visible_to_finance_alone(nb):
refused(api("GET", "/cost-rates", EMPLOYEE, {"company_code": "UK01"}), 403, "ts:cost_rate:write")
rows = must(api("GET", "/cost-rates", FINANCE, {"company_code": "UK01"}))["cost_rates"]
assert {(r["rate_group"], r["rate_amount"]) for r in rows} == {("JUNIOR", "28.0000"), ("SENIOR", "52.0000"), ("PRINCIPAL", "75.0000")}
refused(api("POST", "/cost-rates", EMPLOYEE, {"company_code": "UK01", "rate_group": "SENIOR", "rate_amount": "1", "currency": "GBP", "valid_from": "2027-01-01"}),
403, "ts:cost_rate:write")
for bad in ("0", "-5", "12345678", "1.23456", "x"):
assert api("POST", "/cost-rates", FINANCE, {"company_code": "UK01", "rate_group": "SENIOR", "rate_amount": bad, "currency": "GBP",
"valid_from": "2027-01-01"})[0] == 422
# ── TS-10 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_10_only_the_owner_holds_the_controls_and_a_change_is_a_dated_row_after_today(nb):
refused(api("POST", "/controls", FINANCE, {"key": "missing_hours_mode", "value": "warn", "valid_from": "2026-12-01"}), 403, "ts:settings:controls")
got = must(api("POST", "/controls", OWNER, {"key": "approval_route", "value": "project_lead_then_manager", "valid_from": "2026-12-01"}), 201)
assert got["valid_from"] == "2026-12-01"
with use_cases.repository() as repo:
assert repo.settings.on("approval_route", date(2026, 11, 30)) == "manager" and repo.settings.on("approval_route", date(2026, 12, 1)) == "project_lead_then_manager"
refused(api("POST", "/controls", OWNER, {"key": "approval_route", "value": "manager", "valid_from": "2026-11-20"}), 422, "after today") # a change starts later
refused(api("POST", "/controls", OWNER, {"key": "approval_route", "value": "manager", "valid_from": "2026-12-01"}), 409) # one row per key and date
for bad in ({"key": "approval_route", "value": "anyone", "valid_from": "2026-12-05"}, {"key": "max_day_hours", "value": "99", "valid_from": "2026-12-05"},
{"key": "business_timezone", "value": "Mars/Base", "valid_from": "2026-12-05"}, {"key": "missing_hours_mode", "value": "block", "valid_from": "2026-11-01"}):
assert api("POST", "/controls", OWNER, bad)[0] == 422, bad
def test_ts_10_a_week_keeps_the_route_in_force_on_its_monday(nb):
must(api("POST", "/controls", OWNER, {"key": "approval_route", "value": "project_lead_then_manager", "valid_from": "2026-12-01"}), 201)
open_week()
story_week()
assert submit()["approval_route"] == "manager"
# ── TS-11 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_11_the_labour_journal_is_hours_times_the_standard_rate_and_balances_to_the_cent(nb):
approved_week()
(j,) = nb.journals.values()
assert (j["source_module"], j["event"], j["company_code"], j["journal_date"], j["currency"]) == ("timesheets", "approve", "UK01", "2026-11-23", "GBP")
assert j["lines"] == [{"purpose": "project_labour", "cost_centre": "DELIV-1", "debit": "988.00"},
{"purpose": "project_labour", "cost_centre": "DELIV-2", "debit": "390.00"},
{"purpose": "labour_recovery", "cost_centre": "CONS-LON", "credit": "1378.00"}] # ADMIN on the home centre and the leave post nothing
from erp_platform.seam import derived_id
assert j["journal_id"] == str(derived_id("ts:4410:2026-W47:r1", "approve")) and sheet(SHEET)["journal_id"] == j["journal_id"]
def test_ts_11_a_retry_is_gls_replay_and_a_closed_period_leaves_the_cost_pending_with_gls_reason(nb):
nb.closed_through = "2026-11-30"
s = approved_week()
assert s["status"] == "approved" and nb.journals == {} # the sheet stays approved; nothing posted
(gl,) = [o for o in s["outbox"] if o["target"] == "gl"]
assert gl["status"] == "pending" and "period_open" in gl["last_error"] and "closed" in gl["last_error"]
assert [o["status"] for o in s["outbox"] if o["target"] == "sales-order"] == ["sent"] # the deliveries do not wait for the ledger
refused(api("POST", "/outbox/drain", EMPLOYEE), 403, "ts:cost:post_auto")
assert must(api("POST", "/outbox/drain", SYSTEM))["sent"][0]["status"] == "refused"
nb.closed_through = None
out = must(api("POST", "/outbox/drain", SYSTEM))
assert [x["status"] for x in out["sent"]] == ["sent"] and len(nb.journals) == 1
assert must(api("POST", "/outbox/drain", SYSTEM))["sent"] == [] # nothing pending: nothing sent twice
posts = nb.journal_posts
from brick_timesheets.application import use_cases as uc
with uc.repository() as repo: # a timeout after gl committed: the row is pending again
row = repo.outbox.get("gl", "ts:4410:2026-W47:r1:approve")
repo.outbox.mark({**row, "status": "pending"}, "pending", "did not answer", uc._now())
must(api("POST", "/outbox/drain", SYSTEM))
assert nb.journal_posts == posts + 1 and len(nb.journals) == 1 # gl replayed the derived id: still one journal
def test_ts_11_time_that_posts_nothing_posts_no_journal(nb):
open_week()
a = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "cost_centre": "CONS-LON"}), 201)["lines"][-1]["line_no"]
hours(SHEET, a, {f"2026-11-{d}": "7.50" for d in ("16", "17", "18", "19")})
submit()
s = approve()[1]
assert s["status"] == "approved" and s["journal_id"] is None and nb.journals == {} and nb.deliveries == {}
def test_ts_11_time_on_another_internal_cost_centre_is_charged_to_it_and_relieved_from_home(nb):
open_week()
a = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "cost_centre": "OPS-LON"}), 201)["lines"][-1]["line_no"]
hours(SHEET, a, {f"2026-11-{d}": "7.50" for d in ("16", "17", "18", "19")})
submit()
approve()
(j,) = nb.journals.values()
assert j["lines"] == [{"purpose": "project_labour", "cost_centre": "OPS-LON", "debit": "1560.00"},
{"purpose": "labour_recovery", "cost_centre": "CONS-LON", "credit": "1560.00"}] # 30.00 x 52.0000
# ── TS-12 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_12_billable_hours_on_a_sales_order_project_are_delivered_on_the_orders_service_line_once(nb):
approved_week()
assert nb.deliveries == {"ts:4410:2026-W47:r1:2": {"sales_order_id": "quote:Q-2026-0042@v2", "line_no": 2, "quantity": "19.0000", "source": "timesheets",
"delivery_ref": "ts:4410:2026-W47:r1:2", "delivered_on": "2026-11-23"}}
assert nb.orders_book["quote:Q-2026-0042@v2"][2]["delivered"] == Decimal("59") # 40 + 19
refused(api("POST", "/deliveries/report", EMPLOYEE), 403, "ts:delivery:report")
assert must(api("POST", "/deliveries/report", SYSTEM))["sent"] == [] # nothing pending
from brick_timesheets.application import use_cases as uc
with uc.repository() as repo:
row = repo.outbox.get("sales-order", "ts:4410:2026-W47:r1:2")
repo.outbox.mark({**row, "status": "pending"}, "pending", "timeout", uc._now())
must(api("POST", "/deliveries/report", SYSTEM))
assert nb.delivery_posts == 2 and nb.orders_book["quote:Q-2026-0042@v2"][2]["delivered"] == Decimal("59") # the order replays the ref: recorded once
def test_ts_12_hours_beyond_the_ordered_are_refused_by_the_order_and_stay_pending_with_its_reason(nb):
nb.orders_book["quote:Q-2026-0042@v2"][2]["delivered"] = Decimal("110") # 10 left of 120
s = approved_week()
(d,) = [o for o in s["outbox"] if o["target"] == "sales-order"]
assert d["status"] == "pending" and "within_ordered" in d["last_error"] and s["status"] == "approved"
assert len(nb.journals) == 1 and nb.deliveries == {} # the cost is not held up, the delivery is not dropped
nb.orders_book["quote:Q-2026-0042@v2"][2]["ordered"] = Decimal("200") # the order is amended
assert must(api("POST", "/deliveries/report", SYSTEM))["sent"][0]["status"] == "sent"
def test_ts_12_fixed_price_non_billable_unticked_and_contract_hours_are_not_delivered_to_an_order(nb):
open_week()
a = line(SHEET, project_id="pj:manual:FIXED-1", activity_code="DEV")["lines"][-1]["line_no"]
b = line(SHEET, project_id=P1, activity_code="DEV", billable=False)["lines"][-1]["line_no"]
c = line(SHEET, project_id=P2, activity_code="DEV")["lines"][-1]["line_no"]
hours(SHEET, a, {"2026-11-16": "7.50", "2026-11-17": "7.50"})
hours(SHEET, b, {"2026-11-18": "7.50"})
hours(SHEET, c, {"2026-11-19": "7.50"})
submit()
approve()
assert nb.deliveries == {} and nb.delivery_posts == 0 and len(nb.journals) == 1
# ── TS-16 ────────────────────────────────────────────────────────────────────────────────────
def submitted_colleague():
open_week(EMPLOYEE2)
n = cover(SHEET2, EMPLOYEE2, project=P1, item_id=ITEM)
submit(SHEET2, EMPLOYEE2)
return n
def test_ts_16_an_absence_that_arrives_after_submission_makes_the_line_stale_and_approval_is_refused(nb):
submitted_colleague()
nb.absence(4425, "UK01-TOF-2026-00500", "SICK", ["2026-11-19"])
s = sheet(SHEET2, EMPLOYEE2)
assert "time-off holds 7.50 hours of absence on 2026-11-19" in s["stale"]["1"] and s["status"] == "submitted"
body = refused(api("POST", f"/sheets/{SHEET2}/approve", MANAGER), 409, "not_stale")
assert body["gate"] == "not_stale" and "1" in body["stale"]
assert sheet(SHEET2, EMPLOYEE2)["status"] == "submitted" # nothing was posted
assert nb.journals == {}
must(api("POST", f"/sheets/{SHEET2}/return", MANAGER, {"reason": "you were off sick on Thursday"})) # the person corrects it by a return
def test_ts_16_a_project_item_marked_done_makes_the_line_stale_and_approved_revisions_are_never_flagged(nb):
submitted_colleague()
nb.projects[P1]["items"][0]["state"] = "done"
assert "is not open on 2026-11-16" in sheet(SHEET2, EMPLOYEE2)["stale"]["1"]
refused(api("POST", f"/sheets/{SHEET2}/approve", MANAGER), 409, "not_stale")
nb.projects[P1]["items"][0]["state"] = "open"
nb.projects[P1]["cost_centre"] = "DELIV-9" # a change of terms is stale too
assert "terms of" in sheet(SHEET2, EMPLOYEE2)["stale"]["1"]
nb.projects[P1]["cost_centre"] = "DELIV-1"
assert approve(SHEET2, MANAGER)[0] == 200
nb.absence(4425, "UK01-TOF-2026-00501", "SICK", ["2026-11-19"])
nb.projects[P1]["state"] = "closed"
assert "stale" not in sheet(SHEET2, EMPLOYEE2) and sheet(SHEET2, EMPLOYEE2)["lines"][0]["stale"] is False
# ── TS-17 .. TS-21: the neighbours, as the callee's spec describes them ─────────────────────────────
def test_ts_17_projects_say_what_is_bookable_how_it_bills_and_who_leads_it(nb):
open_week()
n = cover(SHEET, EMPLOYEE, project=P1, days=("16", "17"))
first = dict(nb.project_calls)
submit_failed = api("POST", f"/sheets/{SHEET}/submit", EMPLOYEE)
assert submit_failed[0] == 409 # the week is short; the checks still ran
assert nb.project_calls["bookable"] - first["bookable"] == 2 # one bookable call per distinct day, not per line
assert nb.project_calls["terms"] - first["terms"] == 1 # one terms call per distinct project
ln = sheet()["lines"][-1]
assert (ln["lead_person_id"], ln["cost_centre"], ln["sales_order_id"], ln["bill_via"], n) == ("3020", "DELIV-1", "quote:Q-2026-0042@v2", "sales_order", n)
def test_ts_17_without_projects_time_is_booked_to_cost_centres_only(nb):
from erp_platform import seam
seam.unregister_local("projects")
open_week()
refused(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "DEV", "project_id": P1}), 409, "projects is not part of this installation")
a = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "cost_centre": "CONS-LON"}), 201)["lines"][-1]["line_no"]
hours(SHEET, a, {f"2026-11-{d}": "7.50" for d in ("16", "17", "18", "19")})
assert submit()["status"] == "submitted"
def test_ts_18_hr_gives_the_person_the_pattern_the_home_cost_centre_and_the_approver_and_nothing_else_is_copied(nb):
s = open_week()
assert (s["person_id"], s["company_code"], s["home_cost_centre"], s["expected_hours"]) == ("4410", "UK01", "CONS-LON", "37.50")
nb.cost_centres[4410] = "CONS-EDI"
nb.minutes[4410] = 480
assert sheet()["expected_hours"] == "37.50" and sheet()["home_cost_centre"] == "CONS-LON" # copied once, at opening
def test_ts_19_a_cancelled_absence_leaves_an_open_week_and_a_new_one_arrives(nb):
open_week()
assert sheet()["absence_hours"] == "7.50"
nb.absences[0]["status"] = "cancelled"
nb.absence(4410, "UK01-TOF-2026-00600", "SICK", ["2026-11-17", "2026-11-18"], minutes=450)
s = must(api("POST", f"/sheets/{SHEET}/refresh", EMPLOYEE))
assert [(x["absence_ref"], x["hours"]) for x in by_kind(s, "absence")] == [("UK01-TOF-2026-00600", "15.00")]
assert [x["line_no"] for x in by_kind(s, "absence")] == [2] # a line number is never reused
assert s["booked_hours"] == "15.00"
nb.absences[1]["status"] = "recorded"
assert must(api("POST", f"/sheets/{SHEET}/refresh", EMPLOYEE))["booked_hours"] == "15.00" # recorded counts too
def test_ts_19_submission_reads_time_off_again_so_a_week_is_never_submitted_on_yesterdays_leave(nb):
open_week()
story_week()
nb.absences[0]["status"] = "cancelled" # the Friday leave was cancelled after opening
body = refused(api("POST", f"/sheets/{SHEET}/submit", EMPLOYEE), 409, "2026-11-20: 0.00 of 7.50")
assert body["gate"] == "hours_covered"
def test_ts_21_an_internal_line_on_a_closed_cost_centre_is_refused_at_submission_and_a_closed_period_leaves_cost_pending(nb):
open_week()
a = must(api("POST", f"/sheets/{SHEET}/lines", EMPLOYEE, {"activity_code": "ADMIN", "cost_centre": "OPS-LON"}), 201)["lines"][-1]["line_no"]
hours(SHEET, a, {f"2026-11-{d}": "7.50" for d in ("16", "17", "18", "19")})
nb.gl_cost_centres["OPS-LON"] = "closed"
body = refused(api("POST", f"/sheets/{SHEET}/submit", EMPLOYEE), 409, "cost centre OPS-LON is closed")
assert body["gate"] == "cost_centre_open"
nb.gl_cost_centres["OPS-LON"] = "open"
assert submit()["status"] == "submitted"
from erp_platform import seam
seam.unregister_local("gl") # no ledger: the cost waits, the approval stands
s = approve()[1]
assert s["status"] == "approved" and "no ledger is installed" in s["outbox"][0]["last_error"]
# ── TS-20 ────────────────────────────────────────────────────────────────────────────────────
def test_ts_20_payroll_reads_normal_and_overtime_hours_per_day_for_approved_revisions_only(nb):
approved_week()
got = must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}))
assert got["company_code"] == "UK01" and got["from"] == "2026-11-16"
assert [(r["work_date"], r["normal_hours"], r["absence_hours"], r["status"], r["revision"]) for r in got["rows"]] == [
("2026-11-16", "7.50", "0.00", "approved", 1), ("2026-11-17", "7.50", "0.00", "approved", 1), ("2026-11-18", "7.50", "0.00", "approved", 1),
("2026-11-19", "7.50", "0.00", "approved", 1), ("2026-11-20", "0.00", "7.50", "approved", 1)]
assert {r["overtime_hours"] for r in got["rows"]} == {"0.00"}
open_week(EMPLOYEE2)
assert must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22", "person_id": "4425"}))["rows"] == [] # open: unread
def test_ts_20_hours_above_the_contract_are_overtime_and_an_irregular_worker_has_none(nb):
nb.employee(4470, "emp4470@uk.example", manager=nb.as_manager(2280, "mgr2280@uk.example"))
nb.minutes[4470] = 0 # irregular hours: no contract
who = person("emp4470@uk.example", ["ts:sheet:write_own", "ts:sheet:read_own", "ts:sheet:submit"])
with use_cases.repository() as repo:
repo.rates.put_person(4470, "JUNIOR", date(2025, 1, 1), "fin", use_cases._now())
open_week(who)
n = cover("4470:2026-W47", who, days=("16", "17", "18", "19"))
submit("4470:2026-W47", who)
approve("4470:2026-W47")
rows = must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22", "person_id": "4470"}))["rows"]
assert [(r["normal_hours"], r["overtime_hours"]) for r in rows] == [("7.50", "0.00")] * 4 and n
open_week(EMPLOYEE2)
m = cover(SHEET2, EMPLOYEE2, project=P1, days=("16", "17", "18", "19", "20"))
hours(SHEET2, m, {"2026-11-20": "10.00"}, EMPLOYEE2)
submit(SHEET2, EMPLOYEE2)
approve(SHEET2)
over = must(api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22", "person_id": "4425"}))["rows"]
assert [(r["work_date"], r["normal_hours"], r["overtime_hours"]) for r in over][-1] == ("2026-11-20", "7.50", "2.50") # 40.00 against 37.50 contracted
assert sum(Decimal(r["overtime_hours"]) for r in over) == Decimal("2.50")
def test_ts_20_the_reader_is_a_machine_for_one_company_and_a_short_range(nb):
approved_week()
refused(api("GET", "/approved-hours", EMPLOYEE, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}), 403, "ts:hours:read_approved")
refused(api("GET", "/approved-hours", FINANCE, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}), 403, "ts:hours:read_approved")
refused(api("GET", "/approved-hours", SYSTEM, {"company_code": "UK01", "from": "2026-11-16", "to": "2026-11-22"}), 403, "ts:hours:read_approved")
assert must(api("GET", "/approved-hours", PAYROLL, {"company_code": "DE01", "from": "2026-11-16", "to": "2026-11-22"}))["rows"] == [] # another company's: none
for params in ({"from": "2026-01-01", "to": "2026-12-31"}, {"from": "2026-11-22", "to": "2026-11-16"}, {"from": "2026-11-16", "to": "garbage"},
{"from": "2026-11-16", "to": "2026-11-22", "changed_since": "yesterday"}):
assert api("GET", "/approved-hours", PAYROLL, {"company_code": "UK01", **params})[0] == 422
# ── the lists ────────────────────────────────────────────────────────────────────────────────
def test_lists_show_each_person_their_own_weeks_the_approver_their_waiting_weeks_and_finance_all(nb):
open_week()
story_week()
submit()
open_week(EMPLOYEE2)
assert [t["sheet_id"] for t in must(api("GET", "/sheets", EMPLOYEE))["timesheets"]] == [SHEET]
assert [t["sheet_id"] for t in must(api("GET", "/sheets", MANAGER, {"scope": "waiting"}))["timesheets"]] == [SHEET] # 4425's is still open
assert sorted(t["sheet_id"] for t in must(api("GET", "/sheets", FINANCE, {"scope": "all"}))["timesheets"]) == [SHEET, SHEET2]
refused(api("GET", "/sheets", EMPLOYEE, {"scope": "all"}), 403, "ts:sheet:read_all")
refused(api("GET", "/sheets", EMPLOYEE, {"scope": "waiting"}), 403)
assert api("GET", "/sheets", EMPLOYEE, {"scope": "bogus"})[0] == 422
assert [t["sheet_id"] for t in must(api("GET", "/sheets", LEADER, {"scope": "waiting"}))["timesheets"]] == [SHEET] # a line on a project 3020 leads
refused(api("GET", f"/sheets/{SHEET}", EMPLOYEE2), 404)
assert {a["activity_code"] for a in must(api("GET", "/activities", EMPLOYEE))["activities"]} == {"DEV", "MEETING", "ADMIN"}
assert must(api("POST", "/activities", FINANCE, {"company_code": "UK01", "activity_code": "travel", "name": "Travel", "billable_default": True,
"valid_from": "2026-11-01"}), 201)["activity_code"] == "TRAVEL"
assert "TRAVEL" in {a["activity_code"] for a in must(api("GET", "/activities", EMPLOYEE))["activities"]}