Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user