2026-10-16 22:46:56 -06:00
|
|
|
"""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
|
2026-10-18 15:08:34 -06:00
|
|
|
from . import packets, search
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
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)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
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,
|
2026-10-18 15:08:34 -06:00
|
|
|
filters: Mapping[str, Any] | None = None,
|
2026-10-16 22:46:56 -06:00
|
|
|
) -> dict[str, Any]:
|
2026-10-18 15:08:34 -06:00
|
|
|
"""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."""
|
2026-10-16 22:46:56 -06:00
|
|
|
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},
|
|
|
|
|
)
|
|
|
|
|
]
|
2026-10-18 15:08:34 -06:00
|
|
|
narrow = search.narrowing(filters or {})
|
|
|
|
|
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
|
|
|
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"],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
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),
|
2026-10-16 22:46:56 -06:00
|
|
|
}
|
2026-10-18 15:08:34 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
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."""
|
2026-10-16 22:46:56 -06:00
|
|
|
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),
|
|
|
|
|
)
|
2026-10-18 15:08:34 -06:00
|
|
|
ids = [int(r["id"]) for r in found]
|
|
|
|
|
cards = _cards(conn, pp, ids, **search.board_filters(filters or {}))
|
2026-10-16 22:46:56 -06:00
|
|
|
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"],
|
2026-10-18 15:08:34 -06:00
|
|
|
"facets": search.facets(conn, ids=ids),
|
2026-10-16 22:46:56 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
Build Seth's changes C1 to C11 (Gitea issues 1, 8, 18, 23, 24, 25, 27, 31, 32, 33, 36)
2026-10-30 13:33:51 -06:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
# ---------------------------------------------------------------- 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
|
2026-10-21 18:27:09 -06:00
|
|
|
WHERE u.active AND u.profile_active AND r.dept_code = %s
|
|
|
|
|
AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
|
2026-10-16 22:46:56 -06:00
|
|
|
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]]:
|
2026-10-21 18:27:09 -06:00
|
|
|
"""The active users with a shop role in a department, for the welder picker. An inactive profile is left out."""
|
2026-10-16 22:46:56 -06:00
|
|
|
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}.")
|