Files

492 lines
20 KiB
Python
Raw Permalink Normal View History

"""The shop: the supervisor board, the stations a person may work at, the stall board, and the borrowed heat map.
Everything here reads PostgreSQL only. The cards are the `list_cards` shape plus the shop fields (welder stamp, the
live stage clock, target hours, open change orders as labels, the return note), so a card reads the same on every
screen.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any
from .. import policy
from ..api.errors import ApiError
from . import packets, search
LEAD, SUPERVISOR = 2, 3
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
BOARD_LIMIT = 5000
# The columns of the supervisor board, by packet status. Packets at a station are on the station grid.
COLUMNS = {"ready": "ready", "review": "review", "qc": "qc", "warehouse": "warehouse", "complete": "complete"}
BOARD_STATUSES = ("ready", "assigned", "in_progress", "review", "qc", "warehouse", "complete")
MSG_NO_LEAD = "You do not lead a department."
MSG_NOT_YOURS = "You do not lead that department."
MSG_NO_STATION = "That station is not one of yours."
# ---------------------------------------------------------------- the borrowed heat map
def _has_heat_map(conn: Any, packet_id: int) -> bool:
return bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM heat_table_placements WHERE packet_id = %s)"
" OR EXISTS (SELECT 1 FROM heat_tags WHERE packet_id = %s AND removed_at IS NULL)",
(packet_id, packet_id),
).fetchone()[0]
)
SIBLING_SQL = """
SELECT p.id
FROM packets p
WHERE p.unit_id = %(unit)s AND p.id <> %(id)s AND p.removed_at IS NULL
AND upper(regexp_replace(coalesce(p.drawing, ''), '\\s', '', 'g')) = %(dwg)s
AND (EXISTS (SELECT 1 FROM heat_table_placements h WHERE h.packet_id = p.id)
OR EXISTS (SELECT 1 FROM heat_tags t WHERE t.packet_id = p.id AND t.removed_at IS NULL))
ORDER BY (coalesce(p.rev, '') = %(rev)s) DESC, p.created_at, p.id
LIMIT 1
"""
def dwg_key(drawing: Any) -> str:
"""The drawing number compared between packets: white space removed, upper case (the old dwgKey)."""
return "".join(str(drawing or "").split()).upper()
def heat_src(conn: Any, row: Mapping[str, Any], ch: packets.Children) -> dict[str, Any] | None:
"""The heat map this packet borrows from a sibling, or None (the old heatSrc).
A packet borrows when it has no heat map of its own (no heat table, no heat tag), pulls no raw material of its
own (every BOM line is N/A or a sub-assembly, or the BOM is empty) and has a drawing. The sibling is a live
packet in the same unit with the same drawing and a heat map; the same rev first, then the oldest. Read-only.
"""
if ch.heat_table or ch.heat_tags:
return None
if any(not b["na"] and not packets.is_sub(b) for b in ch.bom):
return None
dwg = dwg_key(row["drawing"])
if not dwg:
return None
found = conn.execute(
SIBLING_SQL, {"unit": row["unit_id"], "id": row["id"], "dwg": dwg, "rev": str(row["rev"] or "")}
).fetchone()
if found is None:
return None
sib = packets.load_row(conn, int(found[0]))
if sib is None:
return None
sch = packets.load_children(conn, sib, full=True)
people = packets.People(conn, [h["by"] for h in sch.heat_table] + [pg["void_by"] for pg in sch.pages])
st = sch.open_stage
return {
"packet_id": sib["id"],
"op": sib["op"],
"status": sib["status"],
"drawing": sib["drawing"],
"rev": sib["rev"],
"pages": [packets._page(pg, sch, people) for pg in sch.pages if pg["type"] == "drawing"],
"heat_table": [
{k: h[k] for k in ("page", "x", "y", "s", "carried_from", "at")} | {"by": people.ref(h["by"])}
for h in sch.heat_table
],
"heat_tags": [packets._heat_tag(t, st) for t in sch.heat_tags],
"bom": [{"item": b["item"], "heat": b["heat"], "na": b["na"]} for b in sch.bom],
}
# ---------------------------------------------------------------- shop cards
EXTRAS_SQL = """
SELECT p.id, p.return_note, p.welder_stamp, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
KPI_SQL = """
SELECT p.id, p.status::text AS status, s.clock_elapsed_ms, s.clock_running_since, s.clock_started_at,
(SELECT coalesce(sum(w.target_hrs), 0) FROM packet_work_orders w WHERE w.packet_id = p.id) AS target_hrs
FROM packets p
LEFT JOIN packet_stages s ON s.packet_id = p.id AND s.closed_at IS NULL
WHERE p.id = ANY(%s)
"""
def add_shop_fields(conn: Any, cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Add the shop fields to list_cards cards, in place: welder_stamp, clock {elapsed_ms, running}, target_hrs,
open_cos [{co_no, label, review}] and return_note."""
if not cards:
return cards
ids = [int(c["id"]) for c in cards]
now = conn.execute("SELECT now()").fetchone()[0]
extras = {int(r["id"]): r for r in packets.rows(conn, EXTRAS_SQL, (ids,))}
cos: dict[int, list[dict[str, Any]]] = {}
for r in packets.rows(
conn,
"SELECT packet_id, co_no, review::text AS review FROM v_open_cos WHERE packet_id = ANY(%s) ORDER BY co_no",
(ids,),
):
cos.setdefault(int(r["packet_id"]), []).append(
{"co_no": r["co_no"], "label": packets.co_label(r["co_no"]), "review": r["review"]}
)
for c in cards:
x = extras.get(int(c["id"])) or {}
if x.get("clock_elapsed_ms") is not None:
clock = packets.clock_of({**x, "closed_at": None}, now)
c["clock"] = {"elapsed_ms": clock["elapsed_ms"], "running": clock["running"]}
else:
c["clock"] = {"elapsed_ms": 0, "running": False}
c["welder_stamp"] = x.get("welder_stamp")
c["target_hrs"] = float(x.get("target_hrs") or 0)
c["return_note"] = x.get("return_note")
c["open_cos"] = cos.get(int(c["id"]), [])
return cards
def _cards(conn: Any, pp: policy.Principal, ids: Iterable[int], **filters: Any) -> list[dict[str, Any]]:
want = list(ids)
if not want:
return []
cards = packets.list_cards(conn, pp, {**filters, "ids": want, "limit": BOARD_LIMIT})
cards.sort(key=lambda c: (c["updated_at"], c["id"]))
return add_shop_fields(conn, cards)
# ---------------------------------------------------------------- scope
def shop_departments(conn: Any) -> list[dict[str, Any]]:
return packets.rows(
conn, "SELECT code, name, weld FROM departments WHERE kind = 'shop' AND active ORDER BY sort, code"
)
def board_scope(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The shop departments the principal leads or supervises (admin: every active shop department), each with
its level name."""
out: list[dict[str, Any]] = []
for d in shop_departments(conn):
level = pp.level(d["code"])
if level >= LEAD:
out.append({**d, "level": LEVEL_NAMES[min(level, SUPERVISOR)]})
elif pp.admin:
out.append({**d, "level": "admin"})
return out
# ---------------------------------------------------------------- the supervisor board
SCOPE_SQL = """
SELECT p.id FROM packets p JOIN units u ON u.id = p.unit_id
WHERE p.removed_at IS NULL AND p.dept_code = ANY(%(depts)s) AND p.status::text = ANY(%(statuses)s) {extra}
ORDER BY p.updated_at, p.id
LIMIT %(limit)s
"""
COS_SQL = """
SELECT c.packet_id, c.co_no, c.review::text AS review, c.hold, c.pending_depts, co.reason, co.ecn, co.issued_at,
p.dept_code, p.op, p.title, pr.number || u.lot AS unit_number
FROM v_open_cos c
JOIN change_orders co ON co.id = c.co_id
JOIN packets p ON p.id = c.packet_id
JOIN units u ON u.id = p.unit_id
JOIN projects pr ON pr.id = u.project_id
WHERE p.removed_at IS NULL AND (p.dept_code = ANY(%(depts)s) OR c.pending_depts && %(depts)s::text[]) {extra}
ORDER BY co.issued_at NULLS LAST, c.packet_id, c.co_no
LIMIT %(limit)s
"""
def _filter_sql(project_id: int | None, unit_id: int | None) -> tuple[str, dict[str, Any]]:
terms: list[str] = []
params: dict[str, Any] = {}
if project_id is not None:
terms.append("u.project_id = %(project_id)s")
params["project_id"] = int(project_id)
if unit_id is not None:
terms.append("p.unit_id = %(unit_id)s")
params["unit_id"] = int(unit_id)
return "".join(f" AND {t}" for t in terms), params
def board(
conn: Any,
pp: policy.Principal,
*,
dept: str | None = None,
project_id: int | None = None,
unit_id: int | None = None,
filters: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""GET /api/shop/board. 403 forbidden when the principal leads no department, or names one outside the scope.
`filters` are the find bar's (search.parse_filters): they narrow the cards of every column and the station
grid. The KPIs follow the department, project and unit only."""
scope = board_scope(conn, pp)
if not scope:
raise ApiError(403, "forbidden", MSG_NO_LEAD)
if dept and dept.lower() != "all":
scope = [d for d in scope if d["code"] == dept]
if not scope:
raise ApiError(403, "forbidden", MSG_NOT_YOURS)
codes = [d["code"] for d in scope]
extra, fparams = _filter_sql(project_id, unit_id)
ids = [
int(r[0])
for r in conn.execute(
SCOPE_SQL.format(extra=extra),
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
)
]
narrow = search.narrowing(filters or {})
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
columns: dict[str, list[dict[str, Any]]] = {k: [] for k in COLUMNS.values()}
at_station: list[dict[str, Any]] = []
for c in cards:
if c["status"] in policy.ACTIVE:
at_station.append(c)
elif c["status"] in COLUMNS:
columns[COLUMNS[c["status"]]].append(c)
stations = packets.rows(
conn,
"SELECT id, name, dept_code FROM stations WHERE active AND dept_code = ANY(%s) ORDER BY dept_code, sort, name",
(codes,),
)
by_station: dict[Any, list[dict[str, Any]]] = {}
for c in at_station:
sid = (c.get("station") or {}).get("id")
by_station.setdefault(sid, []).append(c)
depts = []
for d in scope:
depts.append(
{
"code": d["code"],
"name": d["name"],
"weld": bool(d["weld"]),
"level": d["level"],
"stations": [
{"id": s["id"], "name": s["name"], "packets": by_station.get(s["id"], [])}
for s in stations
if s["dept_code"] == d["code"]
],
}
)
cos = []
for r in packets.rows(conn, COS_SQL.format(extra=extra), {"depts": codes, "limit": BOARD_LIMIT, **fparams}):
pending = list(r["pending_depts"] or [])
cos.append(
{
"packet_id": r["packet_id"],
"unit_number": r["unit_number"],
"op": r["op"],
"title": r["title"],
"dept_code": r["dept_code"],
"co_no": r["co_no"],
"label": packets.co_label(r["co_no"]),
"review": r["review"],
"hold": bool(r["hold"]),
"pending_depts": pending,
"mine": r["review"] == "approved" and any(can_verify(pp, d) for d in pending),
"reason": r["reason"],
"ecn": r["ecn"],
"issued_at": r["issued_at"],
}
)
if narrow:
kpis = _board_kpis(conn, ids)
else:
kpis = {
"ready": len(columns["ready"]),
"at_stations": len(at_station),
"review": len(columns["review"]),
"elapsed_ms": sum(int(c["clock"]["elapsed_ms"]) for c in at_station),
"target_hrs": round(sum(float(c["target_hrs"]) for c in at_station), 3),
}
return {
"depts": depts,
"kpis": kpis,
"cos": cos,
"columns": columns,
"facets": search.facets(conn, ids=ids),
}
def _board_kpis(conn: Any, ids: list[int]) -> dict[str, Any]:
"""The supervisor KPIs over the board's whole scope, when the find bar narrows the cards."""
counts = {"ready": 0, "review": 0, "at_stations": 0}
elapsed = 0
target = 0.0
if ids:
now = conn.execute("SELECT now()").fetchone()[0]
for r in packets.rows(conn, KPI_SQL, (ids,)):
st = r["status"]
if st in ("ready", "review"):
counts[st] += 1
elif st in policy.ACTIVE:
counts["at_stations"] += 1
if r.get("clock_elapsed_ms") is not None:
elapsed += int(packets.clock_of({**r, "closed_at": None}, now)["elapsed_ms"])
target += float(r.get("target_hrs") or 0)
return {**counts, "elapsed_ms": elapsed, "target_hrs": round(target, 3)}
def can_verify(pp: policy.Principal, dept: str) -> bool:
"""A supervisor of the department (admin too: the verify itself asks for the IT reason)."""
if pp.admin:
return True
if dept in ("WH", "QC"):
return dept in pp.support_sup
return pp.level(dept) >= SUPERVISOR
# ---------------------------------------------------------------- stations and the stall board
def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
"""The stations the principal may pick on the Stall screen: every station of each shop department they belong
to at any level, honouring a worker's station limits. Admin: every active station."""
out: list[dict[str, Any]] = []
rows = packets.rows(
conn,
"SELECT s.id, s.name, s.dept_code, d.name AS dept_name FROM stations s"
" JOIN departments d ON d.code = s.dept_code WHERE s.active AND d.active AND d.kind = 'shop'"
" ORDER BY d.sort, d.code, s.sort, s.name",
)
for r in rows:
level = pp.level(r["dept_code"])
if not pp.admin:
if level == 0:
continue
if level < LEAD and pp.station_limits and int(r["id"]) not in pp.station_limits:
continue
out.append({"dept_code": r["dept_code"], "dept_name": r["dept_name"], "id": r["id"], "name": r["name"]})
return out
def stall(conn: Any, pp: policy.Principal, station_id: int, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list.
`filters` (the find bar's) narrow the station's cards; Mine is the station's own work."""
mine_list = {int(s["id"]): s for s in stations_for(conn, pp)}
st = mine_list.get(int(station_id))
if st is None:
raise ApiError(403, "forbidden", MSG_NO_STATION)
found = packets.rows(
conn,
"SELECT id, status::text AS status FROM packets WHERE station_id = %s AND removed_at IS NULL"
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
(int(station_id), BOARD_LIMIT),
)
ids = [int(r["id"]) for r in found]
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
return {
"station": {"id": st["id"], "name": st["name"], "dept_code": st["dept_code"], "dept_name": st["dept_name"]},
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
"facets": search.facets(conn, ids=ids),
}
MSG_NONE_ASSIGNED = "Nothing is assigned to you now. Your supervisor assigns packets to you."
def stall_mine(conn: Any, pp: policy.Principal, filters: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""GET /api/stall/mine: the packets assigned to the signed-in person (welder_id = me), each at the station the
supervisor picked when assigning, across stations (change C11, Gitea issue 36). Nobody picks a station; moving a
welder is a re-assign by the supervisor. The stall board's shape, with `station` None and `station_id` and
`station_name` on every card. `filters` (the find bar's) narrow the cards."""
ids: list[int] = []
if pp.user_id:
found = packets.rows(
conn,
"SELECT id FROM packets WHERE welder_id = %s::uuid AND removed_at IS NULL"
" AND status::text = ANY(%s) ORDER BY updated_at, id LIMIT %s",
(str(pp.user_id), list(search.STALL_STATUSES), BOARD_LIMIT),
)
ids = [int(r["id"]) for r in found]
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
for c in cards:
st = c.get("station") or {}
c["station_id"] = st.get("id")
c["station_name"] = st.get("name")
return {
"station": None,
"mine": [c for c in cards if c["status"] in policy.ACTIVE],
"submitted": [c for c in cards if c["status"] == "review"],
"facets": search.facets(conn, ids=ids),
"empty": None if ids else MSG_NONE_ASSIGNED,
}
# ---------------------------------------------------------------- the assign sheet's people
PEOPLE_SQL = """
SELECT u.id, u.display_name, u.welder_stamp, max(CASE r.role::text WHEN 'shop_supervisor' THEN 3
WHEN 'shop_lead' THEN 2 ELSE 1 END) AS level
FROM users u JOIN user_roles r ON r.user_id = u.id
WHERE u.active AND u.profile_active AND r.dept_code = %s
AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
GROUP BY u.id, u.display_name, u.welder_stamp
ORDER BY lower(u.display_name), u.id
"""
def people(conn: Any, dept: str) -> list[dict[str, Any]]:
"""The active users with a shop role in a department, for the welder picker. An inactive profile is left out."""
found = packets.rows(conn, PEOPLE_SQL, (dept,))
limits: dict[str, list[int]] = {}
if found:
for uid, sid in conn.execute(
"SELECT user_id, station_id FROM user_station_limits WHERE user_id = ANY(%s) ORDER BY station_id",
([r["id"] for r in found],),
):
limits.setdefault(str(uid), []).append(int(sid))
return [
{
"id": str(r["id"]),
"display_name": r["display_name"],
"stamp": r["welder_stamp"],
"level": LEVEL_NAMES[int(r["level"])],
"station_limits": limits.get(str(r["id"]), []),
}
for r in found
]
def welder_check(conn: Any, dept: str, welder_id: str, station_id: int) -> None:
"""ASSIGN: the welder has a shop role in the department, and a worker with station limits is limited to a set
that holds the station. 422 invalid_welder or station_limit."""
level_row = conn.execute(
"SELECT max(CASE r.role::text WHEN 'shop_supervisor' THEN 3 WHEN 'shop_lead' THEN 2 ELSE 1 END)"
" FROM users u JOIN user_roles r ON r.user_id = u.id WHERE u.id = %s AND u.active AND r.dept_code = %s"
" AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')",
(welder_id, dept),
).fetchone()
level = int(level_row[0]) if level_row and level_row[0] is not None else 0
if level == 0:
raise ApiError(422, "invalid_welder", "Pick a welder in this department.")
if level >= LEAD:
return
limits = packets.rows(
conn,
"SELECT s.id, s.name FROM user_station_limits l JOIN stations s ON s.id = l.station_id"
" WHERE l.user_id = %s ORDER BY s.dept_code, s.sort, s.name",
(welder_id,),
)
if limits and int(station_id) not in {int(x["id"]) for x in limits}:
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (welder_id,)).fetchone()[0]
names = ", ".join(str(x["name"]) for x in limits)
raise ApiError(422, "station_limit", f"{name} is limited to {names}.")