624 lines
24 KiB
Python
624 lines
24 KiB
Python
"""Helpers for the phase 8 tests: one database per test module, the Project Management world, and the texts of the
|
|||
|
|
phase 8 contract (periods, PM steps, health, KPI labels, table titles and heads).
|
||
|
|
|
||
|
|
Built on shophelpers, qchelpers and closehelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
|
||
|
|
The labor hours are rows written straight into `visual_labor`, as the feed tests do.
|
||
|
|
|
||
|
|
The PM world (dates are calendar days in the site time zone, America/Denver):
|
||
|
|
|
||
|
|
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
|
||
|
|
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
|
||
|
|
k_short (warehouse pulling, an open short with a past ETA)
|
||
|
|
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
|
||
|
|
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
|
||
|
|
unit 004 "Skid D" due today + 30 -> risk k_co (ready, change order CO-1 open 3 days), k_pulled
|
||
|
|
unit 005 "Skid E" due today + 30 -> done k_filed (filed, created 10 days ago), k_oldfiled (filed 120
|
||
|
|
days ago, created 140 days ago, a synthetic history)
|
||
|
|
unit 006 "Skid F" no due (project's) -> ok k_draft, k_ready, k_asg (assigned), k_complete
|
||
|
|
unit 007 "Skid G" due today + 30 -> empty
|
||
|
|
project L489 "BPX" (due today + 9), unit 052 with no packets -> empty
|
||
|
|
project L333 closed, unit 001 with no packets: not in scope
|
||
|
|
|
||
|
|
History rows inserted with old timestamps (append-only tables, so inserted, never updated):
|
||
|
|
k_draft: return_to_clerical 400 and 45 days ago (sent back)
|
||
|
|
k_oldfiled: send_to_warehouse -139 d, wh_send_back -138 d (sent back), send_to_warehouse -137 d, wh_release -130 d,
|
||
|
|
send_to_qc -126 d, qc_reject -125 d (sent back; a rejected qc_history row), send_to_qc -124 d,
|
||
|
|
qc_accept -123 d (an accepted qc_history row), file -120 d.
|
||
|
|
|
||
|
|
So: open 12, late units 1, units at risk 3, holds 1, change orders 1, shorts 1, over target 1; by period
|
||
|
|
rejects 30: 1, 90: 1, 365: 2, all: 2; filed 30: 1, 90: 1, 365: 2, all: 2;
|
||
|
|
sent back 30: 1, 90: 2, 365: 4, all: 5; lead time 90: 10.0 d, 365: 15.0 d.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import secrets
|
||
|
|
from collections.abc import Iterator
|
||
|
|
from contextlib import contextmanager
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from datetime import date, datetime, timedelta
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fakedir import FakeDirectory
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from pkthelpers import step, tiny_pdf, upload
|
||
|
|
from pshelpers import ORIGIN_HEADERS, PG_URL, app_conn, fake_visual, make_settings
|
||
|
|
from shophelpers import (
|
||
|
|
SIGN,
|
||
|
|
Shop,
|
||
|
|
draft,
|
||
|
|
issue_co,
|
||
|
|
ok,
|
||
|
|
open_shop,
|
||
|
|
pull_all,
|
||
|
|
ready_skipped,
|
||
|
|
run,
|
||
|
|
set_running_since,
|
||
|
|
start,
|
||
|
|
to_station,
|
||
|
|
to_warehouse,
|
||
|
|
)
|
||
|
|
|
||
|
|
from packetstation.settings import Settings
|
||
|
|
|
||
|
|
SITE_TZ = "America/Denver"
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- the contract's texts
|
||
|
|
|
||
|
|
PERIODS = {"30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", "all": "All time"}
|
||
|
|
|
||
|
|
PM_STEPS = (
|
||
|
|
("draft", "Ready to release (Clerical)"),
|
||
|
|
("wh_incoming", "Warehouse: waiting"),
|
||
|
|
("wh_pulling", "Warehouse: being pulled"),
|
||
|
|
("wh_hold", "Warehouse: on hold"),
|
||
|
|
("wh_pulled", "Warehouse: pulled, ready for production"),
|
||
|
|
("ready", "Ready to assign"),
|
||
|
|
("station", "At a station"),
|
||
|
|
("review", "Supervisor review"),
|
||
|
|
("qc", "At Q.C."),
|
||
|
|
("complete", "Back for data book"),
|
||
|
|
)
|
||
|
|
STEP_LABEL = dict(PM_STEPS)
|
||
|
|
|
||
|
|
HEALTH = {"ok": "On track", "risk": "At risk", "late": "Late", "done": "Complete", "empty": "No packets yet"}
|
||
|
|
|
||
|
|
KPI_KEYS = ("open", "late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over", "filed", "lead")
|
||
|
|
KPI_TABS = ("aging", "projects", "projects", "quality", "quality", "quality", "quality", "hours", "projects", "aging")
|
||
|
|
KPI_ALERT = {"late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over"}
|
||
|
|
|
||
|
|
|
||
|
|
def kpi_labels(period: str = "90") -> tuple[str, ...]:
|
||
|
|
lower = PERIODS[period].lower()
|
||
|
|
return (
|
||
|
|
"Open packets",
|
||
|
|
"Late units",
|
||
|
|
"Units at risk",
|
||
|
|
"Warehouse holds",
|
||
|
|
"Open change orders",
|
||
|
|
"Open shorts",
|
||
|
|
f"Q.C. rejects · {lower}",
|
||
|
|
"Packets over target hours",
|
||
|
|
f"Filed · {lower}",
|
||
|
|
"Average lead time, created → filed",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
|
||
|
|
HEADS: dict[str, list[str]] = {
|
||
|
|
"projects": [
|
||
|
|
"Project / unit",
|
||
|
|
"Name",
|
||
|
|
"Customer",
|
||
|
|
"Due",
|
||
|
|
"Left",
|
||
|
|
"Units",
|
||
|
|
"Packets",
|
||
|
|
"Draft",
|
||
|
|
"Warehouse",
|
||
|
|
"Shop",
|
||
|
|
"Q.C.",
|
||
|
|
"Done",
|
||
|
|
"% done",
|
||
|
|
"Health",
|
||
|
|
],
|
||
|
|
"aging_steps": ["Step", "Packets", "Average", "Longest", "Oldest packet"],
|
||
|
|
"aging_open": ["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
|
||
|
|
"aging_history": ["Step", "Moves", "Average", "Longest"],
|
||
|
|
"hours_dept": ["Department", *HOURS_TAIL],
|
||
|
|
"hours_worker": ["Worker", *HOURS_TAIL],
|
||
|
|
"hours_project": ["Project", *HOURS_TAIL],
|
||
|
|
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
|
||
|
|
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
|
||
|
|
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
|
||
|
|
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
|
||
|
|
"quality_holds": ["Job", "Reason", "Note", "Put on hold by", "On hold for"],
|
||
|
|
"quality_sentback": ["Job", "When", "Action", "By", "Reason"],
|
||
|
|
"jobs": [
|
||
|
|
"Job",
|
||
|
|
"Project",
|
||
|
|
"Unit",
|
||
|
|
"Operation",
|
||
|
|
"Title",
|
||
|
|
"Department",
|
||
|
|
"Step",
|
||
|
|
"With",
|
||
|
|
"Station",
|
||
|
|
"Worker",
|
||
|
|
"Drawing",
|
||
|
|
"Rev",
|
||
|
|
"Unit due",
|
||
|
|
"Days in step",
|
||
|
|
"Timer h",
|
||
|
|
"Ticket h",
|
||
|
|
"Target h",
|
||
|
|
"Created",
|
||
|
|
"Updated",
|
||
|
|
"Change orders open",
|
||
|
|
"Shorts open",
|
||
|
|
],
|
||
|
|
}
|
||
|
|
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
|
||
|
|
|
||
|
|
|
||
|
|
def titles(period: str = "90") -> dict[str, str]:
|
||
|
|
p = PERIODS[period]
|
||
|
|
return {
|
||
|
|
"projects": "Projects and units",
|
||
|
|
"aging_steps": "Time in the current step (open packets)",
|
||
|
|
"aging_open": "Every open packet, longest waiting first",
|
||
|
|
"aging_history": f"Average time per step, moves completed ({p})",
|
||
|
|
"hours_dept": "By department",
|
||
|
|
"hours_worker": "By welder / worker",
|
||
|
|
"hours_project": "By project",
|
||
|
|
"hours_over": "Packets over target",
|
||
|
|
"quality_rejects": f"Q.C. rejects ({p})",
|
||
|
|
"quality_shorts": "Open shorts",
|
||
|
|
"quality_cos": "Open change orders",
|
||
|
|
"quality_holds": "Warehouse holds",
|
||
|
|
"quality_sentback": f"Sent back ({p})",
|
||
|
|
"jobs": "All jobs in scope",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
NOTES = {
|
||
|
|
"projects": "Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and"
|
||
|
|
" under 75% done, or a hold or open change order.",
|
||
|
|
"aging_steps": "How long packets have been sitting where they are right now.",
|
||
|
|
"aging_open": "Over 3 days in one step is flagged.",
|
||
|
|
"aging_history": "From the step history recorded on every move.",
|
||
|
|
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
|
||
|
|
" clock of record.",
|
||
|
|
}
|
||
|
|
|
||
|
|
TAB_TABLES = {
|
||
|
|
"aging": ("aging_steps", "aging_open", "aging_history"),
|
||
|
|
"hours": ("hours_dept", "hours_worker", "hours_project", "hours_over"),
|
||
|
|
"quality": ("quality_rejects", "quality_shorts", "quality_cos", "quality_holds", "quality_sentback"),
|
||
|
|
}
|
||
|
|
|
||
|
|
REJECT_REASON = "Undercut on weld 1, regrind and reweld"
|
||
|
|
REJECT_NCR = "NCR-2026-0031"
|
||
|
|
HOLD_REASON = "Waiting on purchasing / PO"
|
||
|
|
HOLD_NOTE = "PO 4471"
|
||
|
|
FORMULA_TITLE = "=HYPERLINK(1)"
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- one database per module
|
||
|
|
|
||
|
|
|
||
|
|
def today_site() -> date:
|
||
|
|
return datetime.now(ZoneInfo(SITE_TZ)).date()
|
||
|
|
|
||
|
|
|
||
|
|
@contextmanager
|
||
|
|
def module_settings(tmp: Path) -> Iterator[Settings]:
|
||
|
|
"""The conftest `pg_settings` for a whole module: a fresh database with ps_migrate and ps_app, migrated to head,
|
||
|
|
dropped afterwards. Lets a module build its world once."""
|
||
|
|
import psycopg
|
||
|
|
from psycopg import sql
|
||
|
|
from psycopg.conninfo import conninfo_to_dict
|
||
|
|
|
||
|
|
from packetstation import db, migrate
|
||
|
|
|
||
|
|
assert PG_URL
|
||
|
|
info = conninfo_to_dict(PG_URL)
|
||
|
|
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||
|
|
(tmp / "mig").write_text(mig_pw + "\n")
|
||
|
|
(tmp / "app").write_text(app_pw + "\n")
|
||
|
|
name = f"ps_test_{secrets.token_hex(4)}"
|
||
|
|
s = make_settings(
|
||
|
|
tmp,
|
||
|
|
pg_host=info.get("host", "localhost"),
|
||
|
|
pg_port=int(info.get("port", 5432)),
|
||
|
|
pg_database=name,
|
||
|
|
pg_migrate_password_file=tmp / "mig",
|
||
|
|
pg_app_password_file=tmp / "app",
|
||
|
|
pg_superuser_url=PG_URL,
|
||
|
|
# A world takes minutes to build over a slow tunnel: no idle lock or idle sign-out while it does.
|
||
|
|
session_lock_minutes=600,
|
||
|
|
session_idle_minutes=720,
|
||
|
|
)
|
||
|
|
migrate.bootstrap_roles(s)
|
||
|
|
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||
|
|
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||
|
|
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||
|
|
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||
|
|
migrate.run(conn, db.MIGRATIONS_DIR)
|
||
|
|
try:
|
||
|
|
yield s
|
||
|
|
finally:
|
||
|
|
db.close_pool()
|
||
|
|
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||
|
|
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||
|
|
|
||
|
|
|
||
|
|
@contextmanager
|
||
|
|
def module_shop(tmp: Path, fake_dir: FakeDirectory | None = None) -> Iterator[Shop]:
|
||
|
|
"""The app on a module database with the fake directory and DEV VISUAL settings; every test user signed in."""
|
||
|
|
from packetstation import guard
|
||
|
|
|
||
|
|
with pytest.MonkeyPatch.context() as mp:
|
||
|
|
mp.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
|
||
|
|
with module_settings(tmp) as s:
|
||
|
|
gen = open_shop(s, fake_dir or FakeDirectory())
|
||
|
|
shop = next(gen)
|
||
|
|
try:
|
||
|
|
yield shop
|
||
|
|
finally:
|
||
|
|
gen.close()
|
||
|
|
|
||
|
|
|
||
|
|
def anon(shop: Shop) -> TestClient:
|
||
|
|
"""A client with no session."""
|
||
|
|
return TestClient(shop.main.app, headers=ORIGIN_HEADERS)
|
||
|
|
|
||
|
|
|
||
|
|
def get_ok(c: TestClient, url: str, **params: Any) -> dict[str, Any]:
|
||
|
|
r = c.get(url, params={k: v for k, v in params.items() if v is not None})
|
||
|
|
assert r.status_code == 200, (url, params, r.text)
|
||
|
|
out: dict[str, Any] = r.json()
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- SQL setup
|
||
|
|
|
||
|
|
|
||
|
|
def make_project(
|
||
|
|
settings: Settings,
|
||
|
|
number: str,
|
||
|
|
name: str,
|
||
|
|
*,
|
||
|
|
customer: str | None = None,
|
||
|
|
po: str | None = None,
|
||
|
|
due: date | None = None,
|
||
|
|
status: str = "open",
|
||
|
|
) -> int:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
row = conn.execute(
|
||
|
|
"INSERT INTO projects (number, name, customer, po, due, status)"
|
||
|
|
" VALUES (%s, %s, %s, %s, %s, %s::project_status) RETURNING id",
|
||
|
|
(number, name, customer, po, due, status),
|
||
|
|
).fetchone()
|
||
|
|
return int(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def make_unit(settings: Settings, project_id: int, lot: str, name: str | None = None, due: date | None = None) -> int:
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
row = conn.execute(
|
||
|
|
"INSERT INTO units (project_id, lot, name, due) VALUES (%s, %s, %s, %s) RETURNING id",
|
||
|
|
(project_id, lot, name, due),
|
||
|
|
).fetchone()
|
||
|
|
return int(row[0])
|
||
|
|
|
||
|
|
|
||
|
|
def add_alias(settings: Settings, project_id: int, alias: str) -> None:
|
||
|
|
run(
|
||
|
|
settings,
|
||
|
|
"INSERT INTO project_aliases (alias, project_id, kind) VALUES (%s, %s, 'import')",
|
||
|
|
(alias.upper(), project_id),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def transition_at(
|
||
|
|
settings: Settings,
|
||
|
|
packet_id: int,
|
||
|
|
days_ago: float,
|
||
|
|
action: str,
|
||
|
|
from_status: str | None,
|
||
|
|
to_status: str | None,
|
||
|
|
*,
|
||
|
|
by: str = "clerk.clara",
|
||
|
|
reason: str | None = None,
|
||
|
|
dept: str = "03",
|
||
|
|
) -> None:
|
||
|
|
"""One packet_transitions row with an old timestamp (the table is append-only: insert, never update)."""
|
||
|
|
run(
|
||
|
|
settings,
|
||
|
|
"INSERT INTO packet_transitions (packet_id, at, by, action, from_status, to_status, from_dept, to_dept,"
|
||
|
|
" reason) VALUES (%s, now() - make_interval(secs => %s), (SELECT id FROM users WHERE sam_account = %s),"
|
||
|
|
" %s, %s::packet_status, %s::packet_status, %s, %s, %s)",
|
||
|
|
(packet_id, days_ago * 86400, by, action, from_status, to_status, dept, dept, reason),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def qc_history_at(settings: Settings, packet_id: int, days_ago: float, result: str, note: str | None = None) -> None:
|
||
|
|
run(
|
||
|
|
settings,
|
||
|
|
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
|
||
|
|
" VALUES (%s, now() - make_interval(secs => %s), %s, 'Quinn QC', %s, '03', '{}')",
|
||
|
|
(packet_id, days_ago * 86400, result, note),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def labor_rows(settings: Settings, packet_id: int, rows: list[tuple[str, int, str]]) -> None:
|
||
|
|
"""Make a packet a VISUAL packet and give its work orders VISUAL ticket hours: rows of (wo_base, seq, hours).
|
||
|
|
Each work order gets the seq of its row; visual_labor gets one row per entry, as the feed writes them."""
|
||
|
|
with app_conn(settings) as conn:
|
||
|
|
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (packet_id,))
|
||
|
|
for base, seq, hours in rows:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE packet_work_orders SET seq = %s WHERE packet_id = %s AND wo_base = %s", (seq, packet_id, base)
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
|
||
|
|
" SELECT wo_base, lot, split, sub, seq, %s, 2, current_date FROM packet_work_orders"
|
||
|
|
" WHERE packet_id = %s AND wo_base = %s"
|
||
|
|
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours",
|
||
|
|
(hours, packet_id, base),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def quick_draft(
|
||
|
|
shop: Shop, unit_id: int, title: str, *, n: int, send: str | None = None, **over: Any
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""A hand draft by clerk.clara with neutral values (drawing D-61nn, work order Wnn XY, no BOM) unless
|
||
|
|
overridden. `send` "warehouse" sends it to the warehouse; a department code releases it there (ready). Either
|
||
|
|
attaches a one-page drawing and answers the heat map first. A bare draft costs one request."""
|
||
|
|
body: dict[str, Any] = {
|
||
|
|
"unit_id": unit_id,
|
||
|
|
"dept_code": "03",
|
||
|
|
"op": "WELD-PART",
|
||
|
|
"title": title,
|
||
|
|
"customer": "Plains Midstream",
|
||
|
|
"drawing": f"D-61{n:02d}",
|
||
|
|
"rev": "A",
|
||
|
|
"wps": "WPS-11",
|
||
|
|
"work_orders": [
|
||
|
|
{"wo_base": f"W{n:02d}XY", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1},
|
||
|
|
],
|
||
|
|
"bom": [],
|
||
|
|
}
|
||
|
|
body.update(over)
|
||
|
|
c = shop("clerk.clara")
|
||
|
|
r = c.post("/api/packets", json=body)
|
||
|
|
assert r.status_code == 201, r.text
|
||
|
|
pkt: dict[str, Any] = r.json()
|
||
|
|
if send:
|
||
|
|
assert c.patch(f"/api/packets/{pkt['id']}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
|
||
|
|
a = upload(c, tiny_pdf(1))
|
||
|
|
assert a.status_code == 201, a.text
|
||
|
|
pkt = ok(
|
||
|
|
c.post(
|
||
|
|
f"/api/packets/{pkt['id']}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
|
||
|
|
)
|
||
|
|
)
|
||
|
|
if send == "warehouse":
|
||
|
|
pkt = ok(step(c, pkt, "send_to_warehouse"))
|
||
|
|
assert pkt["status"] == "warehouse", pkt["status"]
|
||
|
|
else:
|
||
|
|
pkt = ok(step(c, pkt, "release_to_shop", dept=send))
|
||
|
|
assert pkt["status"] == "ready", pkt["status"]
|
||
|
|
return pkt
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- the PM world
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class PmWorld:
|
||
|
|
shop: Shop
|
||
|
|
today: date
|
||
|
|
projects: dict[str, int] = field(default_factory=dict) # "L670", "L489", "L333"
|
||
|
|
units: dict[int, int] = field(default_factory=dict) # 1..7 -> L670 units; 52 -> L489 052
|
||
|
|
k: dict[str, int] = field(default_factory=dict) # packet ids by key
|
||
|
|
|
||
|
|
@property
|
||
|
|
def settings(self) -> Settings:
|
||
|
|
return self.shop.settings
|
||
|
|
|
||
|
|
def unit_number(self, n: int) -> str:
|
||
|
|
return f"L670{n:03d}"
|
||
|
|
|
||
|
|
|
||
|
|
OPEN_KEYS = (
|
||
|
|
"k_run",
|
||
|
|
"k_short",
|
||
|
|
"k_rev",
|
||
|
|
"k_qc",
|
||
|
|
"k_hold",
|
||
|
|
"k_inc",
|
||
|
|
"k_co",
|
||
|
|
"k_pulled",
|
||
|
|
"k_draft",
|
||
|
|
"k_ready",
|
||
|
|
"k_asg",
|
||
|
|
"k_complete",
|
||
|
|
)
|
||
|
|
FILED_KEYS = ("k_filed", "k_oldfiled")
|
||
|
|
|
||
|
|
# The PM step of each open packet, and the expected pipeline counts.
|
||
|
|
STEP_OF = {
|
||
|
|
"k_draft": "draft",
|
||
|
|
"k_inc": "wh_incoming",
|
||
|
|
"k_short": "wh_pulling",
|
||
|
|
"k_hold": "wh_hold",
|
||
|
|
"k_pulled": "wh_pulled",
|
||
|
|
"k_ready": "ready",
|
||
|
|
"k_co": "ready",
|
||
|
|
"k_asg": "station",
|
||
|
|
"k_run": "station",
|
||
|
|
"k_rev": "review",
|
||
|
|
"k_qc": "qc",
|
||
|
|
"k_complete": "complete",
|
||
|
|
}
|
||
|
|
PIPELINE_COUNTS = {key: sum(1 for v in STEP_OF.values() if v == key) for key, _ in PM_STEPS}
|
||
|
|
|
||
|
|
UNIT_HEALTH = {1: "late", 2: "risk", 3: "risk", 4: "risk", 5: "done", 6: "ok", 7: "empty"}
|
||
|
|
|
||
|
|
# By period: Q.C. rejects, filed, sent back, lead time.
|
||
|
|
BY_PERIOD = {
|
||
|
|
"30": {"rejects": 1, "filed": 1, "sentback": 1},
|
||
|
|
"90": {"rejects": 1, "filed": 1, "sentback": 2, "lead": "10.0 d"},
|
||
|
|
"365": {"rejects": 2, "filed": 2, "sentback": 4, "lead": "15.0 d"},
|
||
|
|
"all": {"rejects": 2, "filed": 2, "sentback": 5},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def pm_world(shop: Shop) -> PmWorld:
|
||
|
|
"""Build the PM world described at the top of this file. Takes a few minutes over a slow tunnel."""
|
||
|
|
from closehelpers import at_complete, filed
|
||
|
|
from qchelpers import at_qc, reject
|
||
|
|
|
||
|
|
s = shop.settings
|
||
|
|
today = today_site()
|
||
|
|
w = PmWorld(shop, today)
|
||
|
|
pa = make_project(s, "L670", "Pump skids", customer="Acme Gas", po="PO-9", due=today + timedelta(days=20))
|
||
|
|
add_alias(s, pa, "OLD670")
|
||
|
|
w.projects["L670"] = pa
|
||
|
|
dues = {
|
||
|
|
1: today - timedelta(days=1),
|
||
|
|
2: today + timedelta(days=4),
|
||
|
|
3: today + timedelta(days=30),
|
||
|
|
4: today + timedelta(days=30),
|
||
|
|
5: today + timedelta(days=30),
|
||
|
|
6: None,
|
||
|
|
7: today + timedelta(days=30),
|
||
|
|
}
|
||
|
|
for n, due in dues.items():
|
||
|
|
w.units[n] = make_unit(s, pa, f"{n:03d}", f"Skid {'ABCDEFG'[n - 1]}", due)
|
||
|
|
pb = make_project(s, "L489", "BPX", customer="BPX", due=today + timedelta(days=9))
|
||
|
|
w.projects["L489"] = pb
|
||
|
|
w.units[52] = make_unit(s, pb, "052", "48in separator")
|
||
|
|
pc = make_project(s, "L333", "Old closed job", status="closed")
|
||
|
|
w.projects["L333"] = pc
|
||
|
|
make_unit(s, pc, "001", "Closed unit")
|
||
|
|
u = w.units
|
||
|
|
wade = shop("wh.wade")
|
||
|
|
|
||
|
|
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
|
||
|
|
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
|
||
|
|
w.k["k_run"] = run_pkt["id"]
|
||
|
|
run(
|
||
|
|
s,
|
||
|
|
"UPDATE packet_stages SET clock_elapsed_ms = %s WHERE packet_id = %s AND closed_at IS NULL",
|
||
|
|
(4 * 3600 * 1000, run_pkt["id"]),
|
||
|
|
)
|
||
|
|
set_running_since(s, run_pkt["id"], 2 * 3600)
|
||
|
|
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
|
||
|
|
|
||
|
|
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
|
||
|
|
yesterday = (today - timedelta(days=1)).isoformat()
|
||
|
|
short = ok(
|
||
|
|
wade.post(
|
||
|
|
f"/api/packets/{short['id']}/bom/1/short",
|
||
|
|
json={"qty": 1, "reason": "On order", "ref": "PO-77", "eta": yesterday, "expect_open": False},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
w.k["k_short"] = short["id"]
|
||
|
|
|
||
|
|
# unit 002, at risk by the due rule: a packet back in review after a Q.C. reject, a packet at Q.C.
|
||
|
|
rev = at_qc(shop, u[2], title="Rejected spool")
|
||
|
|
rev = ok(reject(shop, rev, REJECT_REASON, ncr=REJECT_NCR))
|
||
|
|
assert rev["status"] == "review", rev["status"]
|
||
|
|
w.k["k_rev"] = rev["id"]
|
||
|
|
w.k["k_qc"] = at_qc(shop, u[2], title="Inspected spool")["id"]
|
||
|
|
|
||
|
|
# unit 003, at risk by a hold: a warehouse hold five days old, a packet waiting
|
||
|
|
hold = to_warehouse(shop, draft(shop, u[3], title="Held spool"))
|
||
|
|
ok(
|
||
|
|
wade.post(
|
||
|
|
f"/api/packets/{hold['id']}/wh",
|
||
|
|
json={"stage": "hold", "reason": HOLD_REASON, "note": HOLD_NOTE, "expect_stage": "incoming"},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
run(
|
||
|
|
s,
|
||
|
|
"UPDATE packet_wh SET at = now() - interval '5 days' WHERE packet_id = %s AND closed_at IS NULL",
|
||
|
|
(hold["id"],),
|
||
|
|
)
|
||
|
|
w.k["k_hold"] = hold["id"]
|
||
|
|
w.k["k_inc"] = to_warehouse(shop, draft(shop, u[3], title="Waiting spool"))["id"]
|
||
|
|
|
||
|
|
# unit 004, at risk by a change order: CO-1 open three days, a packet pulled
|
||
|
|
co = issue_co(shop, ready_skipped(shop, u[4], title="Changed spool"), ["03"])
|
||
|
|
run(s, "UPDATE change_orders SET issued_at = now() - interval '3 days' WHERE packet_id = %s", (co["id"],))
|
||
|
|
w.k["k_co"] = co["id"]
|
||
|
|
pulled = pull_all(shop, to_warehouse(shop, draft(shop, u[4], title="Pulled spool")))
|
||
|
|
ok(wade.post(f"/api/packets/{pulled['id']}/wh", json={"stage": "pulled", "expect_stage": "pulling"}))
|
||
|
|
w.k["k_pulled"] = pulled["id"]
|
||
|
|
|
||
|
|
# unit 005, done: one filed now (created ten days ago), one filed 120 days ago with a synthetic history
|
||
|
|
fp = filed(shop, u[5])
|
||
|
|
run(s, "UPDATE packets SET created_at = now() - interval '10 days' WHERE id = %s", (fp["id"],))
|
||
|
|
w.k["k_filed"] = fp["id"]
|
||
|
|
old = draft(shop, u[5], title="Old filed spool")
|
||
|
|
oid = old["id"]
|
||
|
|
run(
|
||
|
|
s,
|
||
|
|
"UPDATE packets SET status = 'filed', status_at = now() - interval '120 days',"
|
||
|
|
" created_at = now() - interval '140 days' WHERE id = %s",
|
||
|
|
(oid,),
|
||
|
|
)
|
||
|
|
for days, action, frm, to, reason in (
|
||
|
|
(139, "send_to_warehouse", "draft", "warehouse", None),
|
||
|
|
(138, "wh_send_back", "warehouse", "draft", "Wrong BOM"),
|
||
|
|
(137, "send_to_warehouse", "draft", "warehouse", None),
|
||
|
|
(130, "wh_release", "warehouse", "ready", None),
|
||
|
|
(126, "send_to_qc", "review", "qc", None),
|
||
|
|
(125, "qc_reject", "qc", "review", "Porosity at W2"),
|
||
|
|
(124, "send_to_qc", "review", "qc", None),
|
||
|
|
(123, "qc_accept", "qc", "complete", None),
|
||
|
|
(120, "file", "complete", "filed", None),
|
||
|
|
):
|
||
|
|
transition_at(s, oid, days, action, frm, to, reason=reason)
|
||
|
|
qc_history_at(s, oid, 125, "rejected", "Porosity at W2")
|
||
|
|
qc_history_at(s, oid, 123, "accepted")
|
||
|
|
w.k["k_oldfiled"] = oid
|
||
|
|
|
||
|
|
# unit 006, on track: a draft (sent back twice long ago), ready, assigned, complete
|
||
|
|
dr = draft(shop, u[6], title="Draft spool")
|
||
|
|
w.k["k_draft"] = dr["id"]
|
||
|
|
transition_at(s, dr["id"], 400, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Wrong department")
|
||
|
|
transition_at(s, dr["id"], 45, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Missing drawing")
|
||
|
|
w.k["k_ready"] = ready_skipped(shop, u[6], title="Ready spool")["id"]
|
||
|
|
w.k["k_asg"] = to_station(shop, ready_skipped(shop, u[6], title="Assigned spool"), station="Stall 2")["id"]
|
||
|
|
w.k["k_complete"] = at_complete(shop, u[6])["id"]
|
||
|
|
return w
|
||
|
|
|
||
|
|
|
||
|
|
def by_key(rows: list[dict[str, Any]], key: str = "key") -> dict[str, dict[str, Any]]:
|
||
|
|
return {str(r[key]): r for r in rows}
|
||
|
|
|
||
|
|
|
||
|
|
def table(answer: dict[str, Any], key: str) -> dict[str, Any]:
|
||
|
|
found = [t for t in answer["tables"] if t["key"] == key]
|
||
|
|
assert found, (key, [t["key"] for t in answer["tables"]])
|
||
|
|
out: dict[str, Any] = found[0]
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def rows_by_pid(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||
|
|
return {int(r["pid"]): r for r in t["rows"] if r.get("pid") is not None}
|
||
|
|
|
||
|
|
|
||
|
|
def unit_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||
|
|
return {int(r["unit_id"]): r for r in t["rows"] if r.get("lvl") == 1 and r.get("unit_id") is not None}
|
||
|
|
|
||
|
|
|
||
|
|
def project_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||
|
|
return {int(r["project_id"]): r for r in t["rows"] if r.get("lvl") == 0 and r.get("project_id") is not None}
|