393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""Helpers for the phase 5 shop tests: one signed-in client per user, a packet brought to `ready` in 03 through
|
|||
|
|
the warehouse, and the shop steps (assign, start, finish the stage, submit, review).
|
||
|
|
|
||
|
|
The packet: a hand draft in 03 on a fresh unit, two work orders (target 2 h and 2.5 h), three BOM lines, a
|
||
|
|
two-page PDF drawing, heat map answered, sent to the warehouse, every line pulled with a heat by wh.wade, released.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import secrets
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from pkthelpers import phase2_client, seed_users, step, tiny_pdf, upload
|
||
|
|
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
|
||
|
|
from psycopg.types.json import Jsonb
|
||
|
|
|
||
|
|
from packetstation.settings import Settings
|
||
|
|
|
||
|
|
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
|
||
|
|
IT = "Ticket 5501: shop lead is out today"
|
||
|
|
STALE = "Someone else already took this step."
|
||
|
|
|
||
|
|
USERS = (
|
||
|
|
"admin.ann",
|
||
|
|
"clerk.clara",
|
||
|
|
"clerk.leo",
|
||
|
|
"wh.wade",
|
||
|
|
"qc.quinn",
|
||
|
|
"pm.paula",
|
||
|
|
"weld.andy",
|
||
|
|
"lead.lou",
|
||
|
|
"sup.wes",
|
||
|
|
"multi.mia",
|
||
|
|
"both.bo",
|
||
|
|
"dup.dave",
|
||
|
|
)
|
||
|
|
|
||
|
|
WOS = [
|
||
|
|
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 2},
|
||
|
|
{"wo_base": "L670002", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 2.5},
|
||
|
|
]
|
||
|
|
BOM3 = [
|
||
|
|
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B"},
|
||
|
|
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
|
||
|
|
{"item": 3, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
|
||
|
|
]
|
||
|
|
WELD_KEYS = ("dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty")
|
||
|
|
PLAIN_KEYS = ("dwg", "prev", "mat", "work", "insp", "qty")
|
||
|
|
|
||
|
|
MSG_CHECKLIST = "Finish the checklist first."
|
||
|
|
MSG_WELDS = "Record at least one weld first."
|
||
|
|
MSG_FILLER = "Enter the filler metal first."
|
||
|
|
MSG_QC = "Confirm the book and the spool, and give the location."
|
||
|
|
MSG_OTHER_DEPT = "Choose a different department."
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- clients
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Shop:
|
||
|
|
"""One app, one signed-in TestClient per user (separate cookie jars)."""
|
||
|
|
|
||
|
|
main: TestClient
|
||
|
|
settings: Settings
|
||
|
|
clients: dict[str, TestClient] = field(default_factory=dict)
|
||
|
|
|
||
|
|
def __call__(self, sam: str) -> TestClient:
|
||
|
|
c = self.clients.get(sam)
|
||
|
|
if c is None:
|
||
|
|
c = self.fresh(sam)
|
||
|
|
self.clients[sam] = c
|
||
|
|
return c
|
||
|
|
|
||
|
|
def fresh(self, sam: str) -> TestClient:
|
||
|
|
"""A new client with its own session (two sessions of one user, or a thread of its own)."""
|
||
|
|
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
|
||
|
|
r = sign_in(c, sam)
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
return c
|
||
|
|
|
||
|
|
def uid(self, sam: str) -> str:
|
||
|
|
return user_id(self.settings, sam)
|
||
|
|
|
||
|
|
def stall(self, dept: str = "03", name: str = "Stall 1") -> int:
|
||
|
|
return station_id(self.settings, dept, name)
|
||
|
|
|
||
|
|
|
||
|
|
def open_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
|
||
|
|
"""The body of a `shop` fixture: the app, every test user signed in once (their users rows exist)."""
|
||
|
|
with phase2_client(settings, fake_dir) as client:
|
||
|
|
seed_users(client, USERS)
|
||
|
|
yield Shop(client, settings)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- SQL
|
||
|
|
|
||
|
|
|
||
|
|
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
return conn.execute(sql, params).fetchone()
|
||
|
|
|
||
|
|
|
||
|
|
def all_rows(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
return list(conn.execute(sql, params).fetchall())
|
||
|
|
|
||
|
|
|
||
|
|
def run(settings: Settings, sql: str, params: Any = ()) -> None:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
conn.execute(sql, params)
|
||
|
|
|
||
|
|
|
||
|
|
def user_id(settings: Settings, sam: str) -> str:
|
||
|
|
row = one(settings, "SELECT id FROM users WHERE sam_account = %s", (sam,))
|
||
|
|
assert row is not None, sam
|
||
|
|
return str(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def station_id(settings: Settings, dept: str = "03", name: str = "Stall 1") -> int:
|
||
|
|
row = one(settings, "SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name))
|
||
|
|
assert row is not None, (dept, name)
|
||
|
|
return int(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def set_stamp(settings: Settings, sam: str, stamp: str | None) -> None:
|
||
|
|
run(settings, "UPDATE users SET welder_stamp = %s WHERE sam_account = %s", (stamp, sam))
|
||
|
|
|
||
|
|
|
||
|
|
def set_limits(settings: Settings, sam: str, station_ids: list[int]) -> None:
|
||
|
|
"""Replace a user's station limits (an empty list removes them)."""
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
uid = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()[0]
|
||
|
|
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
|
||
|
|
for sid in station_ids:
|
||
|
|
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
|
||
|
|
|
||
|
|
|
||
|
|
def make_unit(settings: Settings, number: str | None = None, lot: str = "001") -> tuple[int, int]:
|
||
|
|
"""A project with one unit by SQL. Returns (project_id, unit_id)."""
|
||
|
|
number = number or "L5" + secrets.token_hex(3).upper()
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
pid = conn.execute(
|
||
|
|
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Shop project', 'Acme Refining', 'PO-55')"
|
||
|
|
" RETURNING id",
|
||
|
|
(number,),
|
||
|
|
).fetchone()[0]
|
||
|
|
uid = conn.execute(
|
||
|
|
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
|
||
|
|
(pid, lot),
|
||
|
|
).fetchone()[0]
|
||
|
|
return int(pid), int(uid)
|
||
|
|
|
||
|
|
|
||
|
|
def set_running_since(settings: Settings, packet_id: int, seconds_ago: float) -> None:
|
||
|
|
"""Move the open stage's running clock back in time, as if it had run that long."""
|
||
|
|
run(
|
||
|
|
settings,
|
||
|
|
"UPDATE packet_stages SET clock_running_since = now() - make_interval(secs => %s)"
|
||
|
|
" WHERE packet_id = %s AND closed_at IS NULL AND clock_running_since IS NOT NULL",
|
||
|
|
(seconds_ago, packet_id),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def set_templates(settings: Settings, value: Any) -> None:
|
||
|
|
run(
|
||
|
|
settings,
|
||
|
|
"INSERT INTO settings (key, value, version, updated_at) VALUES ('checklist_templates', %s, 1, now())"
|
||
|
|
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
|
||
|
|
(Jsonb(value),),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- reads
|
||
|
|
|
||
|
|
|
||
|
|
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
|
||
|
|
r = c.get(f"/api/packets/{pid}")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
|
||
|
|
r = c.get(f"/api/packets/{pid}/log")
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: list[dict[str, Any]] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
|
||
|
|
return log_rows(c, pid)[0]
|
||
|
|
|
||
|
|
|
||
|
|
def find_log(c: TestClient, pid: int, action: str) -> dict[str, Any]:
|
||
|
|
"""The newest log row with this action text."""
|
||
|
|
rows = [r for r in log_rows(c, pid) if r["action"] == action]
|
||
|
|
assert rows, (action, [r["action"] for r in log_rows(c, pid)])
|
||
|
|
return rows[0]
|
||
|
|
|
||
|
|
|
||
|
|
def ok(r: Any) -> dict[str, Any]:
|
||
|
|
assert r.status_code == 200, r.text
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def refused(r: Any, status: int, code: str, detail: str | None = None) -> dict[str, Any]:
|
||
|
|
assert r.status_code == status, r.text
|
||
|
|
body: dict[str, Any] = r.json()
|
||
|
|
assert body["code"] == code, body
|
||
|
|
if detail is not None:
|
||
|
|
assert body["detail"] == detail, body
|
||
|
|
return body
|
||
|
|
|
||
|
|
|
||
|
|
def weld_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
|
||
|
|
return next(w for w in pkt["welds"] if w["weld_no"] == no)
|
||
|
|
|
||
|
|
|
||
|
|
def tag_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
|
||
|
|
return next(t for t in pkt["heat_tags"] if t["tag_no"] == no)
|
||
|
|
|
||
|
|
|
||
|
|
def line_of(pkt: dict[str, Any], item: int) -> dict[str, Any]:
|
||
|
|
return next(b for b in pkt["bom"] if b["item"] == item)
|
||
|
|
|
||
|
|
|
||
|
|
def im(v: int) -> dict[str, str]:
|
||
|
|
return {"If-Match": str(v)}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- the packet
|
||
|
|
|
||
|
|
|
||
|
|
def draft(
|
||
|
|
shop: Shop,
|
||
|
|
unit_id: int | None = None,
|
||
|
|
*,
|
||
|
|
title: str = "Spool 7 outlet",
|
||
|
|
drawing: str = "D-5001",
|
||
|
|
rev: str = "B",
|
||
|
|
bom: list[dict[str, Any]] | None = None,
|
||
|
|
wos: list[dict[str, Any]] | None = None,
|
||
|
|
dept: str = "03",
|
||
|
|
pages: int = 2,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""A hand draft by clerk.clara with the heat map answered and a PDF drawing of `pages` pages."""
|
||
|
|
if unit_id is None:
|
||
|
|
_, unit_id = make_unit(shop.settings)
|
||
|
|
c = shop("clerk.clara")
|
||
|
|
body = {
|
||
|
|
"unit_id": unit_id,
|
||
|
|
"dept_code": dept,
|
||
|
|
"op": "WELD-PART",
|
||
|
|
"title": title,
|
||
|
|
"customer": "Acme Refining",
|
||
|
|
"drawing": drawing,
|
||
|
|
"rev": rev,
|
||
|
|
"wps": "WPS-11",
|
||
|
|
"work_orders": WOS if wos is None else wos,
|
||
|
|
"bom": BOM3 if bom is None else bom,
|
||
|
|
}
|
||
|
|
r = c.post("/api/packets", json=body)
|
||
|
|
assert r.status_code == 201, r.text
|
||
|
|
pid = r.json()["id"]
|
||
|
|
assert c.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||
|
|
a = upload(c, tiny_pdf(pages))
|
||
|
|
assert a.status_code == 201, a.text
|
||
|
|
r = c.post(f"/api/packets/{pid}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN})
|
||
|
|
return ok(r)
|
||
|
|
|
||
|
|
|
||
|
|
def to_warehouse(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
out = ok(step(shop("clerk.clara"), pkt, "send_to_warehouse"))
|
||
|
|
assert out["status"] == "warehouse"
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def pull_all(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
"""wh.wade ticks every line pulled and gives it a heat."""
|
||
|
|
c = shop("wh.wade")
|
||
|
|
for b in list(pkt["bom"]):
|
||
|
|
item = b["item"]
|
||
|
|
cur = line_of(pkt, item)
|
||
|
|
pkt = ok(c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
|
||
|
|
cur = line_of(pkt, item)
|
||
|
|
pkt = ok(
|
||
|
|
c.patch(
|
||
|
|
f"/api/packets/{pkt['id']}/bom/{item}", json={"heat": f"HT-{item}0"}, headers=im(cur["row_version"])
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return pkt
|
||
|
|
|
||
|
|
|
||
|
|
def release(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
out = ok(step(shop("wh.wade"), pkt, "wh_release"))
|
||
|
|
assert out["status"] == "ready" and out["dept"]["code"] == "03", out["status"]
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def ready_packet(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||
|
|
"""Draft, warehouse, every line pulled with a heat, released: `ready` in 03 with an open stage."""
|
||
|
|
pkt = to_warehouse(shop, draft(shop, unit_id, **kw))
|
||
|
|
return release(shop, pull_all(shop, pkt))
|
||
|
|
|
||
|
|
|
||
|
|
def ready_skipped(shop: Shop, unit_id: int | None = None, dept: str = "03", **kw: Any) -> dict[str, Any]:
|
||
|
|
"""A draft sent straight to a department (wh_skipped true)."""
|
||
|
|
pkt = draft(shop, unit_id, **kw)
|
||
|
|
out = ok(step(shop("clerk.clara"), pkt, "release_to_shop", dept=dept))
|
||
|
|
assert out["status"] == "ready" and out["wh_skipped"] is True
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def to_station(
|
||
|
|
shop: Shop,
|
||
|
|
pkt: dict[str, Any],
|
||
|
|
*,
|
||
|
|
by: str = "sup.wes",
|
||
|
|
welder: str = "weld.andy",
|
||
|
|
dept: str = "03",
|
||
|
|
station: str = "Stall 1",
|
||
|
|
**kw: Any,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""ASSIGN the packet to a station and a welder."""
|
||
|
|
out = ok(step(shop(by), pkt, "assign", station_id=shop.stall(dept, station), welder_id=shop.uid(welder), **kw))
|
||
|
|
assert out["status"] == "assigned"
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def start(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
|
||
|
|
out = ok(step(shop(by), pkt, "start"))
|
||
|
|
assert out["status"] == "in_progress"
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def add_weld(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", **over: Any) -> dict[str, Any]:
|
||
|
|
body: dict[str, Any] = {"page": 1, "fx": 0.5, "fy": 0.5, "items": [1, 2], "type": "Fillet", "process": "GMAW"}
|
||
|
|
body.update(over)
|
||
|
|
return ok(shop(by).post(f"/api/packets/{pkt['id']}/welds", json=body))
|
||
|
|
|
||
|
|
|
||
|
|
def tick_all(
|
||
|
|
shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", keys: tuple[str, ...] = WELD_KEYS
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
for k in keys:
|
||
|
|
pkt = ok(shop(by).put(f"/api/packets/{pkt['id']}/stage/checklist/{k}", json={"done": True}))
|
||
|
|
return pkt
|
||
|
|
|
||
|
|
|
||
|
|
def set_filler(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", filler: str = "ER70S-6") -> dict[str, Any]:
|
||
|
|
return ok(shop(by).patch(f"/api/packets/{pkt['id']}/stage", json={"set": {"filler": filler}, "base": {}}))
|
||
|
|
|
||
|
|
|
||
|
|
def finish_stage(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", welds: int = 1) -> dict[str, Any]:
|
||
|
|
"""Every checklist key ticked, `welds` welds marked, the filler set: SUBMIT can pass."""
|
||
|
|
keys = WELD_KEYS if pkt["dept"]["weld"] else PLAIN_KEYS
|
||
|
|
pkt = tick_all(shop, pkt, by, keys)
|
||
|
|
for _ in range(welds):
|
||
|
|
pkt = add_weld(shop, pkt, by)
|
||
|
|
return set_filler(shop, pkt, by)
|
||
|
|
|
||
|
|
|
||
|
|
def submit(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
|
||
|
|
out = ok(step(shop(by), pkt, "submit", sign=SIGN))
|
||
|
|
assert out["status"] == "review"
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def in_progress(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||
|
|
return start(shop, to_station(shop, ready_packet(shop, unit_id, **kw)))
|
||
|
|
|
||
|
|
|
||
|
|
def in_review(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
|
||
|
|
pkt = in_progress(shop, unit_id, **kw)
|
||
|
|
return submit(shop, finish_stage(shop, pkt))
|
||
|
|
|
||
|
|
|
||
|
|
def issue_co(shop: Shop, pkt: dict[str, Any], required: list[str], **over: Any) -> dict[str, Any]:
|
||
|
|
"""A manual change order by clerk.clara (approved at once, holds until every required department verifies)."""
|
||
|
|
body = {"reason": "Nozzle moved 2 in on rev C", "required_depts": required, "sign": SIGN, **over}
|
||
|
|
return ok(shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders", json=body))
|
||
|
|
|
||
|
|
|
||
|
|
def verify(shop: Shop, pkt: dict[str, Any], no: int, dept: str, by: str, **over: Any) -> Any:
|
||
|
|
body = {"dept": dept, "note": "Reviewed with the crew", "sign": SIGN, **over}
|
||
|
|
return shop(by).post(f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json=body)
|