dodil/erp-brick-timesheets

DODIL ERP brick: timesheets

tests/test_reports.py

"""TS-09 project hours and TS-15 the chase."""
import pytest
from conftest import (
    EMPLOYEE,
    EMPLOYEE2,
    FINANCE,
    FINANCE2,
    LEADER,
    MANAGER,
    OWNER,
    P1,
    P2,
    SYSTEM,
    api,
    approve,
    approved_week,
    as_role,
    hours,
    line,
    must,
    on_day,
    open_week,
    refused,
    submit,
)
from erp_platform import capabilities, seam
from fastapi import FastAPI, Response

from brick_timesheets.application import use_cases

SHEET2 = "4425:2026-W47"
MONTH_END = "2026-11-30T10:00:00+00:00"


def cover(sheet_id, who, project, days=("16", "17", "18", "19", "20")):
    n = line(sheet_id, who, project_id=project, activity_code="DEV")["lines"][-1]["line_no"]
    hours(sheet_id, n, {f"2026-11-{d}": "7.50" for d in days}, who)
    return n


def hours_of(project, who=None, start="2026-11-01", end="2026-11-30"):
    return api("GET", "/project-hours", who or LEADER, {"project_id": project, "from": start, "to": end})


# ── TS-09 ─────────────────────────────────────────────────────────────────────────────────────────

def waiting_week_of_4425():
    open_week(EMPLOYEE2)
    cover(SHEET2, EMPLOYEE2, P2)
    submit(SHEET2, EMPLOYEE2)


def test_ts_09_the_lead_reads_approved_billable_billed_unbilled_waiting_and_cost_of_their_project(nb):
    approved_week()
    waiting_week_of_4425()                                                        # 37.50 more on P2, submitted and not approved
    got = must(hours_of(P2))
    assert (got["approved"], got["billable"], got["billed"], got["unbilled"], got["submitted_not_approved"], got["cost"]) == (
        "7.50", "7.50", "0.00", "7.50", "37.50", "390.00")                           # 7.50 x 52.0000; nothing billed before a run is released
    assert got["currency"] == "GBP" and got["items"] == [{"item_id": None, "approved": "7.50", "billable": "7.50", "billed": "0.00", "unbilled": "7.50", "cost": "390.00"}]
    assert got["people"] == [{"person_id": "4410", "approved": "7.50", "billable": "7.50", "billed": "0.00", "unbilled": "7.50", "cost": "390.00"}]
    p1 = must(hours_of(P1))
    assert (p1["approved"], p1["cost"], p1["submitted_not_approved"]) == ("19.00", "988.00", "0.00")     # another project's hours are not in P2's figures
    assert p1["items"][0]["item_id"] == "quote:Q-2026-0042@v2|2|1" or p1["items"][0]["item_id"] is None


def test_ts_09_billed_is_what_released_runs_took_and_a_draft_run_bills_nothing_yet(nb):
    approved_week()
    with on_day(MONTH_END):
        must(api("POST", "/billing-runs", SYSTEM, {"project_id": P2, "period_id": "2026-11"}))
        assert must(hours_of(P2))["billed"] == "0.00"
        must(api("POST", f"/billing-runs/tsbill:{P2}:2026-11/release", FINANCE2))
        got = must(hours_of(P2))
    assert (got["billed"], got["unbilled"], got["billable"]) == ("7.50", "0.00", "7.50")


def test_ts_09_a_corrected_week_counts_its_standing_revision_only(nb):
    approved_week()
    must(api("POST", f"/sheets/{'4410:2026-W47'}/reopen", FINANCE, {"reason": "fix"}))
    assert must(hours_of(P2))["approved"] == "7.50"                              # revision 1 stands until the correction is approved
    hours("4410:2026-W47", 4, {"2026-11-19": "9.50"})
    submit()
    assert must(hours_of(P2))["submitted_not_approved"] == "9.50"
    must(approve())
    got = must(hours_of(P2))
    assert (got["approved"], got["submitted_not_approved"], got["cost"]) == ("9.50", "0.00", "494.00")


def test_ts_09_only_projects_i_lead_and_finance_sees_all(nb):
    approved_week()
    nb.employee(3099, "lead3099@uk.example", manager=None)
    other = as_role("project_lead", "lead3099@uk.example")
    refused(hours_of(P2, other), 404, "that you lead")
    refused(hours_of(P2, EMPLOYEE), 403, "ts:project_hours:read")
    refused(hours_of(P2, MANAGER), 403, "ts:project_hours:read")
    assert must(hours_of(P2, FINANCE))["approved"] == "7.50"
    assert must(hours_of("pj:contract:NOPE", FINANCE))["approved"] == "0.00"        # finance asks about a project nobody booked to: zeros, not an error
    refused(hours_of("pj:contract:NOPE", LEADER), 404)
    refused(hours_of(P2, OWNER), 403, "ts:project_hours:read")                  # the owner's default role reads sheets, not project money


def test_ts_09_the_dates_are_bounded(nb):
    refused(hours_of(P2, FINANCE, "2026-01-01", "2027-06-01"), 422, "366 days")
    refused(hours_of(P2, FINANCE, "2026-11-30", "2026-11-01"), 422, "366 days")
    refused(hours_of(P2, FINANCE, "soon", "2026-11-30"), 422, "date")


# ── TS-15 ─────────────────────────────────────────────────────────────────────────────────────────

MONDAY = "2026-11-23T10:00:00+00:00"


@pytest.fixture
def chase(nb):
    """4425's W47 open since the Monday it ended; 4433's W46 submitted Tuesday 2026-11-17 to manager 2280 (4 business days before Monday 11-23)."""
    nb.employee(4433, "emp4433@uk.example", manager=nb.as_manager(2280, "mgr2280@uk.example"))
    with use_cases.repository() as repo:
        from datetime import date
        repo.settings.put("missing_hours_mode", "warn", date(2026, 1, 1), "owner", use_cases._now())
        repo.rates.put_person(4433, "JUNIOR", date(2025, 4, 1), "setup", use_cases._now())
    third = as_role("employee", "emp4433@uk.example")
    open_week(EMPLOYEE2)
    with on_day("2026-11-17T09:00:00+00:00"):
        s = must(api("POST", "/sheets", third, {"week": "2026-W46"}), 201)["sheet_id"]
        n = line(s, third, project_id=P1, activity_code="DEV")["lines"][-1]["line_no"]
        hours(s, n, {"2026-11-09": "7.50"}, third)
        submit(s, third)
    return s


def tick(at=MONDAY, who=None):
    with on_day(at):
        return api("POST", "/reminders/tick", who or SYSTEM)


@pytest.fixture
def mail():
    sent, state = [], {"status": None}
    app = FastAPI()

    @app.post("/capabilities/mail.send")
    def send(body: dict, response: Response):
        if state["status"]:
            response.status_code = state["status"]
            return {"detail": "down"}
        sent.append(body)
        return {"status": "done", "provider": "gws", "provider_ref": f"m{len(sent)}", "output": {"message_id": f"m{len(sent)}"}}
    seam.register_local("gws", app)
    capabilities.register_provider("mail.send", "gws")
    yield type("Mail", (), {"sent": sent, "state": state})
    capabilities.reset_providers()
    seam.unregister_local("gws")


def test_ts_15_the_person_with_an_open_week_and_the_approver_of_a_waiting_one_are_each_reminded_once_a_business_date(chase, mail):
    first = must(tick())
    assert first["business_date"] == "2026-11-23" and first["reminders"] == 2 and first["unaddressed"] == []
    assert sorted((m["input"]["to"][0], m["input"]["subject"][:26]) for m in mail.sent) == [("emp4425@uk.example", "Your timesheet for 2026-W4"), ("mgr2280@uk.example", "A timesheet is waiting for")]
    box = must(api("GET", "/reminders", FINANCE))["reminders"]
    assert sorted(r["ref"] for r in box) == ["approval:4433:2026-W46:r1:2026-11-23", "submit:4425:2026-W47:r1:2026-11-23"]
    again = must(tick())
    assert (again["reminders"], again["skipped_already_sent"], again["sent"]) == (0, 2, []) and len(mail.sent) == 2        # the second pass the same day sends nothing
    assert must(tick("2026-11-24T10:00:00+00:00"))["reminders"] == 2 and len(mail.sent) == 4                                # tomorrow's pass chases again


def test_ts_15_nothing_is_chased_before_the_week_is_due_or_the_approver_has_waited_long_enough(chase, mail):
    got = must(tick("2026-11-23T09:59:00+00:00"))                                                                           # the 4425's week is due Monday 10:00
    assert [r["ref"] for r in must(api("GET", "/reminders", FINANCE))["reminders"]] == ["approval:4433:2026-W46:r1:2026-11-23"] and got["reminders"] == 1
    with use_cases.repository() as repo:
        from datetime import date
        repo.settings.put("approval_reminder_days", "7", date(2026, 11, 22), "owner", use_cases._now())
    assert must(tick("2026-11-25T10:00:00+00:00"))["reminders"] == 1                                                         # 6 business days waited < 7: only the open week is chased
    assert [m["input"]["to"] for m in mail.sent] == [["mgr2280@uk.example"], ["emp4425@uk.example"]]


def test_ts_15_with_no_mail_provider_a_reminder_is_a_task_for_a_person(chase):
    got = must(tick())
    assert sorted(s["status"] for s in got["sent"]) == ["manual", "manual"]
    assert {r["status"] for r in must(api("GET", "/reminders", FINANCE, {"status": "manual"}))["reminders"]} == {"manual"}


def test_ts_15_a_mail_provider_that_is_down_leaves_the_reminder_pending_and_the_next_pass_sends_it(chase, mail):
    mail.state["status"] = 500
    got = must(tick())
    assert sorted(s["status"] for s in got["sent"]) == ["retry", "retry"] and mail.sent == []
    mail.state["status"] = None
    again = must(tick())
    assert again["reminders"] == 0 and sorted(s["status"] for s in again["sent"]) == ["sent", "sent"] and len(mail.sent) == 2


def test_ts_15_the_pass_is_the_systems_alone_and_a_week_with_no_email_is_named(chase, mail):
    refused(api("POST", "/reminders/tick", FINANCE), 403, "ts:reminder:tick")
    refused(api("GET", "/reminders", EMPLOYEE), 403, "ts:sheet:read_all")
    with use_cases.repository() as repo:
        from sqlalchemy import update  # noqa: F401

        sheet = repo.sheets.get("4425:2026-W47")
        sheet.person = type(sheet.person)("", "")
        repo.sheets.save(sheet, use_cases._now())
    got = must(tick())
    assert got["unaddressed"] == ["4425:2026-W47:r1: no email for the person"] and got["reminders"] == 1