373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""Phase 8: the Project Management read endpoints against the PM world (pmhelpers.py).
|
|||
|
|
|
||
|
|
The world is built once for the module; every test only reads it.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from pmhelpers import (
|
||
|
|
ATTENTION_HEAD,
|
||
|
|
BY_PERIOD,
|
||
|
|
FILED_KEYS,
|
||
|
|
HEADS,
|
||
|
|
HEALTH,
|
||
|
|
HOLD_NOTE,
|
||
|
|
HOLD_REASON,
|
||
|
|
KPI_ALERT,
|
||
|
|
KPI_KEYS,
|
||
|
|
KPI_TABS,
|
||
|
|
NOTES,
|
||
|
|
OPEN_KEYS,
|
||
|
|
PERIODS,
|
||
|
|
PIPELINE_COUNTS,
|
||
|
|
PM_STEPS,
|
||
|
|
REJECT_NCR,
|
||
|
|
REJECT_REASON,
|
||
|
|
STEP_LABEL,
|
||
|
|
STEP_OF,
|
||
|
|
TAB_TABLES,
|
||
|
|
UNIT_HEALTH,
|
||
|
|
PmWorld,
|
||
|
|
anon,
|
||
|
|
by_key,
|
||
|
|
get_ok,
|
||
|
|
kpi_labels,
|
||
|
|
module_shop,
|
||
|
|
pm_world,
|
||
|
|
project_rows,
|
||
|
|
table,
|
||
|
|
titles,
|
||
|
|
unit_rows,
|
||
|
|
)
|
||
|
|
from pshelpers import needs_pg
|
||
|
|
|
||
|
|
pytestmark = needs_pg
|
||
|
|
|
||
|
|
ENDPOINTS = ("overview", "projects", "aging", "hours", "quality", "find")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
|
||
|
|
with module_shop(tmp_path_factory.mktemp("pm_api")) as shop:
|
||
|
|
yield pm_world(shop)
|
||
|
|
|
||
|
|
|
||
|
|
def pm(w: PmWorld, what: str, sam: str = "pm.paula", **params: Any) -> dict[str, Any]:
|
||
|
|
return get_ok(w.shop(sam), f"/api/pm/{what}", **params)
|
||
|
|
|
||
|
|
|
||
|
|
def kpis(answer: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||
|
|
return by_key(answer["kpis"])
|
||
|
|
|
||
|
|
|
||
|
|
def ids(w: PmWorld, *keys: str) -> set[int]:
|
||
|
|
return {w.k[k] for k in keys}
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ overview
|
||
|
|
|
||
|
|
|
||
|
|
def test_overview_kpis(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "overview")
|
||
|
|
assert [k["key"] for k in out["kpis"]] == list(KPI_KEYS)
|
||
|
|
assert [k["label"] for k in out["kpis"]] == list(kpi_labels("90"))
|
||
|
|
assert [k["tab"] for k in out["kpis"]] == list(KPI_TABS)
|
||
|
|
assert {k["key"] for k in out["kpis"] if k["alert"]} == KPI_ALERT
|
||
|
|
n = {k: v["n"] for k, v in kpis(out).items()}
|
||
|
|
assert n == {
|
||
|
|
"open": len(OPEN_KEYS),
|
||
|
|
"late_units": 1,
|
||
|
|
"risk_units": 3,
|
||
|
|
"holds": 1,
|
||
|
|
"cos": 1,
|
||
|
|
"shorts": 1,
|
||
|
|
"rejects": 1,
|
||
|
|
"over": 1,
|
||
|
|
"filed": 1,
|
||
|
|
"lead": "10.0 d",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_overview_pipeline(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "overview")
|
||
|
|
pipe = out["pipeline"]
|
||
|
|
assert [(p["key"], p["label"]) for p in pipe] == list(PM_STEPS)
|
||
|
|
assert {p["key"]: p["count"] for p in pipe} == PIPELINE_COUNTS
|
||
|
|
slow = [p["key"] for p in pipe if p["slowest"]]
|
||
|
|
assert slow == ["wh_hold"] # held five days; every other step is minutes old
|
||
|
|
hold = by_key(pipe)["wh_hold"]
|
||
|
|
assert hold["avg_s"] >= 5 * 86400 - 60 and hold["max_s"] >= hold["avg_s"]
|
||
|
|
assert hold["oldest"]["pid"] == world.k["k_hold"] and hold["oldest"]["job"]
|
||
|
|
empty = [p for p in pipe if p["count"] == 0]
|
||
|
|
assert all(p["oldest"] is None for p in empty)
|
||
|
|
|
||
|
|
|
||
|
|
def test_overview_summary(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "overview")
|
||
|
|
lines = out["summary"]
|
||
|
|
assert lines[0] == f"{len(OPEN_KEYS)} open packets in scope (All projects)."
|
||
|
|
assert lines[1].startswith("Late: ") and world.unit_number(1) in lines[1]
|
||
|
|
most = [x for x in lines if x.startswith("Most packets are at ")]
|
||
|
|
assert most and most[0] in (
|
||
|
|
f'Most packets are at "{STEP_LABEL["ready"]}" (2).',
|
||
|
|
f'Most packets are at "{STEP_LABEL["station"]}" (2).',
|
||
|
|
)
|
||
|
|
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
|
||
|
|
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
|
||
|
|
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
|
||
|
|
assert "1 packet is over target hours." in lines
|
||
|
|
|
||
|
|
|
||
|
|
def test_overview_attention(world: PmWorld) -> None:
|
||
|
|
att = pm(world, "overview")["attention"]
|
||
|
|
assert att["title"] == "Units late or at risk" and att["head"] == ATTENTION_HEAD
|
||
|
|
got = {int(r["unit_id"]): r["health"] for r in att["rows"]}
|
||
|
|
assert got == {world.units[n]: h for n, h in UNIT_HEALTH.items() if h in ("late", "risk")}
|
||
|
|
for r in att["rows"]:
|
||
|
|
assert r["cells"][-1] == HEALTH[r["health"]]
|
||
|
|
late = next(r for r in att["rows"] if r["health"] == "late")
|
||
|
|
assert late["cells"][0] == world.unit_number(1)
|
||
|
|
assert late["cells"][3] == "1 d over"
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ health and projects
|
||
|
|
|
||
|
|
|
||
|
|
def test_health_per_unit_and_project(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "projects", expand="all")
|
||
|
|
t = table(out, "projects")
|
||
|
|
assert t["title"] == titles()["projects"] and t["head"] == HEADS["projects"]
|
||
|
|
assert t["note"] == NOTES["projects"]
|
||
|
|
units = unit_rows(t)
|
||
|
|
for n, health in UNIT_HEALTH.items():
|
||
|
|
row = units[world.units[n]]
|
||
|
|
assert row["health"] == health, (n, row)
|
||
|
|
assert row["cells"][-1] == HEALTH[health]
|
||
|
|
assert row["project_id"] == world.projects["L670"]
|
||
|
|
assert units[world.units[5]]["cells"][12].startswith("100")
|
||
|
|
assert units[world.units[7]]["cells"][6] == "0"
|
||
|
|
projects = project_rows(t)
|
||
|
|
assert projects[world.projects["L670"]]["health"] == "late" # as healthy as its worst unit
|
||
|
|
assert projects[world.projects["L670"]]["cells"][-1] == "Late"
|
||
|
|
assert projects[world.projects["L489"]]["health"] == "empty"
|
||
|
|
assert world.projects["L333"] not in projects # closed, no packet in scope
|
||
|
|
assert units[world.units[52]]["health"] == "empty"
|
||
|
|
# the order: each project row, then its units
|
||
|
|
lvl = [(r["lvl"], r["project_id"]) for r in t["rows"]]
|
||
|
|
first = lvl.index((0, world.projects["L670"]))
|
||
|
|
assert all(x == (1, world.projects["L670"]) for x in lvl[first + 1 : first + 8])
|
||
|
|
|
||
|
|
|
||
|
|
def test_drill_down(world: PmWorld) -> None:
|
||
|
|
pa, pb = world.projects["L670"], world.projects["L489"]
|
||
|
|
closed = pm(world, "projects")
|
||
|
|
t = table(closed, "projects")
|
||
|
|
assert {r["lvl"] for r in t["rows"]} == {0}
|
||
|
|
assert set(project_rows(t)) == {pa, pb}
|
||
|
|
one = table(pm(world, "projects", expand=str(pb)), "projects")
|
||
|
|
assert set(unit_rows(one)) == {world.units[52]}
|
||
|
|
both = table(pm(world, "projects", expand=f"{pa},{pb}"), "projects")
|
||
|
|
assert set(unit_rows(both)) == set(world.units.values())
|
||
|
|
picked = table(pm(world, "projects", project_id=pa), "projects")
|
||
|
|
assert set(project_rows(picked)) == {pa}
|
||
|
|
assert set(unit_rows(picked)) == {world.units[n] for n in range(1, 8)}
|
||
|
|
# any filter other than the project: only units with a packet in scope
|
||
|
|
at_qc = table(pm(world, "projects", status="qc", expand="all"), "projects")
|
||
|
|
assert set(unit_rows(at_qc)) == {world.units[2]}
|
||
|
|
assert pa in project_rows(at_qc)
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ aging, hours, quality
|
||
|
|
|
||
|
|
|
||
|
|
def test_aging(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "aging")
|
||
|
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["aging"])
|
||
|
|
assert [p["key"] for p in out["pipeline"]] == [k for k, _ in PM_STEPS]
|
||
|
|
for key in TAB_TABLES["aging"]:
|
||
|
|
t = table(out, key)
|
||
|
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key] and t["note"] == NOTES[key]
|
||
|
|
steps = {r["cells"][0]: r for r in table(out, "aging_steps")["rows"]}
|
||
|
|
assert steps[STEP_LABEL["wh_hold"]]["cells"][1] == "1"
|
||
|
|
assert steps[STEP_LABEL["ready"]]["cells"][1] == "2"
|
||
|
|
opened = table(out, "aging_open")
|
||
|
|
assert {int(r["pid"]) for r in opened["rows"]} == ids(world, *OPEN_KEYS)
|
||
|
|
assert opened["rows"][0]["pid"] == world.k["k_hold"] and opened["rows"][0]["flag"] is True
|
||
|
|
assert opened["rows"][0]["cells"][2] == STEP_LABEL["wh_hold"]
|
||
|
|
fresh_rows = [r for r in opened["rows"] if r["pid"] in ids(world, "k_ready", "k_asg", "k_qc")]
|
||
|
|
assert fresh_rows and not any(r["flag"] for r in fresh_rows)
|
||
|
|
steps_of = {int(r["pid"]): r["cells"][2] for r in opened["rows"]}
|
||
|
|
for key in OPEN_KEYS:
|
||
|
|
assert steps_of[world.k[key]] == STEP_LABEL[STEP_OF[key]], key
|
||
|
|
assert table(out, "aging_history")["rows"], "the moves made by the world"
|
||
|
|
|
||
|
|
|
||
|
|
def test_hours(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "hours")
|
||
|
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["hours"])
|
||
|
|
for key in TAB_TABLES["hours"]:
|
||
|
|
t = table(out, key)
|
||
|
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||
|
|
assert table(out, "hours_over")["note"] == NOTES["hours_over"]
|
||
|
|
dept = table(out, "hours_dept")["rows"]
|
||
|
|
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
|
||
|
|
# counted: the running packet, and every packet past review with target hours
|
||
|
|
assert dept[0]["cells"][1] == "6"
|
||
|
|
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
|
||
|
|
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
|
||
|
|
assert dept[0]["cells"][7] == "1"
|
||
|
|
over = table(out, "hours_over")["rows"]
|
||
|
|
assert [r["pid"] for r in over] == [world.k["k_run"]]
|
||
|
|
cells = over[0]["cells"]
|
||
|
|
# 4 h stored plus the running span (2 h when the world was built, more by now)
|
||
|
|
timer = float(cells[3])
|
||
|
|
assert 6.0 <= timer < 8.0, cells
|
||
|
|
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
|
||
|
|
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
|
||
|
|
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
|
||
|
|
assert set(workers) == {"Andy Welder", "—"}
|
||
|
|
assert workers["Andy Welder"]["cells"][1] == "5" and workers["—"]["cells"][1] == "1"
|
||
|
|
assert workers["Andy Welder"]["cells"][7] == "1"
|
||
|
|
projects = table(out, "hours_project")["rows"]
|
||
|
|
assert [r["cells"][0] for r in projects] == ["L670 Pump skids"]
|
||
|
|
variance = [r["cells"][5] for r in table(out, "hours_worker")["rows"]]
|
||
|
|
assert all(v.startswith(("+", "-", "0")) for v in variance)
|
||
|
|
|
||
|
|
|
||
|
|
def test_quality(world: PmWorld) -> None:
|
||
|
|
out = pm(world, "quality")
|
||
|
|
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["quality"])
|
||
|
|
for key in TAB_TABLES["quality"]:
|
||
|
|
t = table(out, key)
|
||
|
|
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
|
||
|
|
rej = table(out, "quality_rejects")["rows"]
|
||
|
|
assert [r["pid"] for r in rej] == [world.k["k_rev"]]
|
||
|
|
assert rej[0]["cells"][2] == "—" # the reject was made before an inspector name was entered
|
||
|
|
assert REJECT_REASON in rej[0]["cells"][3] and REJECT_NCR in rej[0]["cells"][3]
|
||
|
|
shorts = table(out, "quality_shorts")["rows"]
|
||
|
|
assert [r["pid"] for r in shorts] == [world.k["k_short"]]
|
||
|
|
assert shorts[0]["flag"] is True and shorts[0]["cells"][5].endswith(" (late)")
|
||
|
|
assert shorts[0]["cells"][3] == "On order" and shorts[0]["cells"][4] == "PO-77"
|
||
|
|
assert shorts[0]["cells"][1].startswith("1 ") and shorts[0]["cells"][7] == STEP_LABEL["wh_pulling"]
|
||
|
|
cos = table(out, "quality_cos")["rows"]
|
||
|
|
assert [r["pid"] for r in cos] == [world.k["k_co"]]
|
||
|
|
assert cos[0]["cells"][1].startswith("CO-1") and cos[0]["flag"] is True # open three days
|
||
|
|
assert cos[0]["cells"][4] == "3.0 d"
|
||
|
|
holds = table(out, "quality_holds")["rows"]
|
||
|
|
assert [r["pid"] for r in holds] == [world.k["k_hold"]] and holds[0]["flag"] is True
|
||
|
|
assert holds[0]["cells"][1:4] == [HOLD_REASON, HOLD_NOTE, "Wade Warehouse"]
|
||
|
|
assert holds[0]["cells"][4] == "5.0 d"
|
||
|
|
back = table(out, "quality_sentback")["rows"]
|
||
|
|
assert [r["pid"] for r in back] == [world.k["k_rev"], world.k["k_draft"]] # newest first
|
||
|
|
assert back[1]["cells"][3] == "Wes Super" and back[1]["cells"][4] == "Missing drawing"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("period", ["30", "90", "365", "all"])
|
||
|
|
def test_every_period(world: PmWorld, period: str) -> None:
|
||
|
|
want = BY_PERIOD[period]
|
||
|
|
ov = pm(world, "overview", period=period)
|
||
|
|
sc = ov["scope"]["period"]
|
||
|
|
assert sc["value"] == period and sc["label"] == PERIODS[period]
|
||
|
|
assert (sc["start"] is None) == (period == "all")
|
||
|
|
assert [k["label"] for k in ov["kpis"]] == list(kpi_labels(period))
|
||
|
|
n = {k: v["n"] for k, v in kpis(ov).items()}
|
||
|
|
assert n["rejects"] == want["rejects"] and n["filed"] == want["filed"]
|
||
|
|
if "lead" in want:
|
||
|
|
assert n["lead"] == want["lead"]
|
||
|
|
q = pm(world, "quality", period=period)
|
||
|
|
assert len(table(q, "quality_sentback")["rows"]) == want["sentback"]
|
||
|
|
assert len(table(q, "quality_rejects")["rows"]) == want["rejects"]
|
||
|
|
assert table(q, "quality_rejects")["title"] == titles(period)["quality_rejects"]
|
||
|
|
# the open counts do not follow the period
|
||
|
|
assert n["open"] == len(OPEN_KEYS) and n["holds"] == 1
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ find, scope, access
|
||
|
|
|
||
|
|
|
||
|
|
def test_find_sorted(world: PmWorld) -> None:
|
||
|
|
every = ids(world, *OPEN_KEYS, *FILED_KEYS)
|
||
|
|
out = pm(world, "find", sort="project")
|
||
|
|
got = [c["id"] for c in out["packets"]]
|
||
|
|
assert set(got) == every and len(got) == len(every)
|
||
|
|
units = [c["unit"]["number"] for c in out["packets"]]
|
||
|
|
assert units == sorted(units)
|
||
|
|
due = pm(world, "find", sort="due")["packets"]
|
||
|
|
assert due[0]["unit"]["number"] == world.unit_number(1)
|
||
|
|
late = [c["due_effective"] for c in due]
|
||
|
|
assert late == sorted(late, key=lambda d: (d is None, d or ""))
|
||
|
|
upd = [c["updated_at"] for c in pm(world, "find")["packets"]]
|
||
|
|
assert upd == sorted(upd, reverse=True)
|
||
|
|
scoped = pm(world, "find", unit_id=world.units[2])["packets"]
|
||
|
|
assert {c["id"] for c in scoped} == ids(world, "k_rev", "k_qc")
|
||
|
|
|
||
|
|
|
||
|
|
def test_scope_label(world: PmWorld) -> None:
|
||
|
|
pa = world.projects["L670"]
|
||
|
|
sc = pm(world, "overview")["scope"]
|
||
|
|
assert sc["label"] == "All projects" and sc["project"] is None and sc["unit"] is None
|
||
|
|
assert sc["count"] == len(pm(world, "find")["packets"])
|
||
|
|
sc = pm(world, "overview", project_id=pa)["scope"]
|
||
|
|
assert sc["label"] == "Project L670"
|
||
|
|
assert sc["project"] == {"id": pa, "number": "L670", "name": "Pump skids"}
|
||
|
|
sc = pm(world, "overview", project_id=pa, unit_id=world.units[1])["scope"]
|
||
|
|
assert sc["label"].startswith("Unit L670001") and sc["unit"] == {"id": world.units[1], "number": "L670001"}
|
||
|
|
assert sc["count"] == 2
|
||
|
|
sc = pm(world, "overview", q="spool")["scope"]
|
||
|
|
assert sc["label"] == 'All projects · search "spool"'
|
||
|
|
sc = pm(world, "overview", project_id=pa, dept="03", q="held")["scope"]
|
||
|
|
parts = sc["label"].split(" · ")
|
||
|
|
assert parts[0] == "Project L670" and parts[-1] == 'search "held"' and len(parts) == 3
|
||
|
|
assert sc["count"] == 1
|
||
|
|
flags = pm(world, "overview", flags="short")["scope"]
|
||
|
|
assert flags["label"].startswith("All projects · ") and flags["count"] == 1
|
||
|
|
# mine is ignored on the PM screens
|
||
|
|
assert pm(world, "overview", flags="mine")["scope"]["count"] == sc_count_all(world)
|
||
|
|
|
||
|
|
|
||
|
|
def sc_count_all(world: PmWorld) -> int:
|
||
|
|
return int(pm(world, "overview")["scope"]["count"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_scope_follows_the_project(world: PmWorld) -> None:
|
||
|
|
pb = world.projects["L489"]
|
||
|
|
out = pm(world, "overview", project_id=pb)
|
||
|
|
n = {k: v["n"] for k, v in kpis(out).items()}
|
||
|
|
assert n["open"] == 0 and n["holds"] == 0 and n["late_units"] == 0
|
||
|
|
assert out["attention"]["rows"] == []
|
||
|
|
q = pm(world, "quality", project_id=pb)
|
||
|
|
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
|
||
|
|
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
|
||
|
|
for what in ENDPOINTS:
|
||
|
|
r = world.shop(sam).get(f"/api/pm/{what}")
|
||
|
|
assert r.status_code == 200, (sam, what, r.text)
|
||
|
|
|
||
|
|
|
||
|
|
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
|
||
|
|
c = anon(world.shop)
|
||
|
|
for what in ENDPOINTS:
|
||
|
|
r = c.get(f"/api/pm/{what}")
|
||
|
|
assert r.status_code == 401, (what, r.text)
|
||
|
|
for what in ENDPOINTS:
|
||
|
|
r = world.shop("pm.paula").post(f"/api/pm/{what}", json={})
|
||
|
|
assert r.status_code in (404, 405), (what, r.status_code)
|
||
|
|
# the PM user cannot move a packet
|
||
|
|
pkt = get_ok(world.shop("pm.paula"), f"/api/packets/{world.k['k_ready']}")
|
||
|
|
r = world.shop("pm.paula").post(
|
||
|
|
f"/api/packets/{pkt['id']}/transitions",
|
||
|
|
json={"action": "assign", "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}},
|
||
|
|
)
|
||
|
|
assert r.status_code == 403, r.text
|
||
|
|
|
||
|
|
|
||
|
|
def test_bad_period_is_refused(world: PmWorld) -> None:
|
||
|
|
r = world.shop("pm.paula").get("/api/pm/overview", params={"period": "7"})
|
||
|
|
assert r.status_code in (400, 422), r.text
|