"""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), } # ---------------------------------------------------------------- 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 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.""" 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}.")