Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -18,7 +18,7 @@ from psycopg.rows import dict_row
|
||||
from .. import policy
|
||||
from ..api import deps
|
||||
from ..api.errors import ApiError
|
||||
from . import checklists
|
||||
from . import checklists, search
|
||||
|
||||
HAND_FLAG = "Made by hand - check it and send it on"
|
||||
DRAWING_NEEDED_FLAG = "Made from VISUAL - drawing not attached yet"
|
||||
@@ -1246,7 +1246,7 @@ SELECT p.id, p.version, p.unit_id, u.lot, pr.number || u.lot AS unit_number, pr.
|
||||
st.name AS station_name, p.welder_id, wu.display_name AS welder_name, p.welder_stamp,
|
||||
p.routed_to::text AS routed_to, p.vclosed_at, p.vclosed_by, vu.display_name AS vclosed_name,
|
||||
p.source_kind::text AS source_kind, p.flags, p.drawing_state::text AS drawing_state, p.wh_skipped,
|
||||
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.visual,
|
||||
p.heat_map, p.removed_at, coalesce(u.due, pr.due) AS due_effective, p.updated_at, p.created_at, p.visual,
|
||||
p.visual_closed_at, p.visual_cancelled_at, p.wo_gone_at,
|
||||
EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id AND c.review = 'pending'
|
||||
AND c.origin IN ('visual', 'drawing')) AS co_pending,
|
||||
@@ -1277,13 +1277,6 @@ LEFT JOIN LATERAL (
|
||||
) bc ON true
|
||||
"""
|
||||
|
||||
FLAG_ON = ("1", "true", "yes", "any", "flagged")
|
||||
FLAG_OFF = ("0", "false", "no", "none")
|
||||
|
||||
|
||||
def _like(word: str) -> str:
|
||||
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
|
||||
|
||||
def card_state(r: Mapping[str, Any], names: Mapping[str, str]) -> policy.PacketState:
|
||||
"""PacketState from header columns and counts. Child-row facts the board does not load pass (the packet
|
||||
@@ -1331,7 +1324,8 @@ def wh_card(r: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr)."""
|
||||
"""The WHERE terms and parameters of the card filters, over CARD_SQL's aliases (p, u, pr, d, st, wu). The find
|
||||
bar terms (status words, q, flag, flags, station_id, welder_id) come from domain/search.py."""
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
if filters.get("ids") is not None:
|
||||
@@ -1339,7 +1333,7 @@ def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||
params.append([int(i) for i in filters["ids"]])
|
||||
if not filters.get("removed"):
|
||||
where.append("p.removed_at IS NULL")
|
||||
status = [s.strip() for s in str(filters.get("status") or "").split(",") if s.strip()]
|
||||
status = search.status_terms(str(filters.get("status") or ""))
|
||||
if status:
|
||||
where.append("p.status::text = ANY(%s)")
|
||||
params.append(status)
|
||||
@@ -1352,55 +1346,29 @@ def card_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||
if filters.get("project_id") is not None:
|
||||
where.append("u.project_id = %s")
|
||||
params.append(int(filters["project_id"]))
|
||||
flag = str(filters.get("flag") or "").strip().lower()
|
||||
if flag in FLAG_ON:
|
||||
where.append(FLAG_SQL)
|
||||
elif flag in FLAG_OFF:
|
||||
where.append("NOT " + FLAG_SQL)
|
||||
elif flag == "hand":
|
||||
# Made by hand, or a VISUAL draft with a drawing or BOM uploaded by hand.
|
||||
where.append("(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))")
|
||||
params.append(list(HAND_UPLOAD_FLAGS))
|
||||
elif flag in ("hand_upload", "hand_uploads"):
|
||||
where.append("(p.status = 'draft' AND p.flags && %s::text[])")
|
||||
params.append(list(HAND_UPLOAD_FLAGS))
|
||||
elif flag and RX_FLAG_KIND.match(flag):
|
||||
# One flag kind on the Flagged board (drafts). A visual draft whose flags are empty but whose drawing state
|
||||
# still names the problem counts too, the same way flag_of reads it.
|
||||
states = [st for st, k in STATE_KINDS.items() if k == flag]
|
||||
where.append(
|
||||
"(p.status = 'draft' AND (%s = ANY(p.flags) OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
|
||||
" AND p.drawing_state::text = ANY(%s))))"
|
||||
)
|
||||
params += [flag, states]
|
||||
co = str(filters.get("co") or "").strip().lower()
|
||||
if co == "pending":
|
||||
where.append(CO_PENDING_SQL)
|
||||
for word in str(filters.get("q") or "").split()[:8]:
|
||||
pat = _like(word)
|
||||
where.append(
|
||||
"(pr.number ILIKE %s OR pr.name ILIKE %s OR (pr.number || u.lot) ILIKE %s OR u.lot ILIKE %s"
|
||||
" OR p.drawing ILIKE %s OR p.customer ILIKE %s OR pr.customer ILIKE %s OR pr.po ILIKE %s"
|
||||
" OR p.title ILIKE %s OR EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
||||
" AND wo.wo_base ILIKE %s))"
|
||||
)
|
||||
params += [pat] * 10
|
||||
return where, params
|
||||
w, p = search.filter_where(filters)
|
||||
return where + w, params + p
|
||||
|
||||
|
||||
def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The board cards. Filters: status (comma list), dept, unit_id, project_id, q, flag (1|0|hand|hand_upload|
|
||||
<kind>), co (pending), ids (packet ids), limit, removed."""
|
||||
"""The board cards. Filters: status (comma list of raw statuses or find bar words), dept, unit_id, project_id,
|
||||
q, flag (a flag kind value or the old raw values), flags (quick flags; mine needs a MineScope in `mine`),
|
||||
station_id, welder_id, co (pending), ids (packet ids), sort (updated|project|due|status|dept), limit, removed,
|
||||
lite (skip `can` and `blocked`, for the reports)."""
|
||||
where, params = card_where(filters)
|
||||
sql = CARD_SQL + (" WHERE " + " AND ".join(where) if where else "")
|
||||
sql += " ORDER BY p.updated_at DESC, p.id DESC LIMIT %s"
|
||||
sql += " ORDER BY " + search.order_by(filters.get("sort")) + " LIMIT %s"
|
||||
params.append(int(filters.get("limit") or 500))
|
||||
lite = bool(filters.get("lite"))
|
||||
names = dept_labels(conn)
|
||||
transitions = {str(a) for a in policy.TRANSITIONS}
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows(conn, sql, params):
|
||||
state = card_state(r, names)
|
||||
allowed, blocked = policy.can(policy_principal, state)
|
||||
allowed, blocked = ([], {}) if lite else policy.can(policy_principal, state)
|
||||
welder = (
|
||||
None
|
||||
if r["welder_id"] is None
|
||||
@@ -1448,6 +1416,7 @@ def list_cards(conn: Any, policy_principal: policy.Principal, filters: Mapping[s
|
||||
"bom_missing_heat": int(r["bom_missing_heat"] or 0),
|
||||
"due_effective": r["due_effective"],
|
||||
"updated_at": r["updated_at"],
|
||||
"created_at": r["created_at"],
|
||||
"holder": policy.holder_name(state, r["dept_name"], r["station_name"]),
|
||||
"can": [str(a) for a in allowed if str(a) in transitions],
|
||||
"blocked": {str(a): v for a, v in blocked.items() if str(a) in transitions},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,7 @@ def record(
|
||||
conn: Any,
|
||||
p: deps.Principal,
|
||||
*,
|
||||
action: Literal["print", "export"],
|
||||
action: Literal["print", "export", "report exported"],
|
||||
kind: str,
|
||||
what: str,
|
||||
pages: int | None,
|
||||
@@ -114,8 +114,9 @@ def record(
|
||||
stamp_sig: str | None = None,
|
||||
detail: Mapping[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""One print_log row and one audit row (action `print` or `export`), in one transaction. The audit detail of an
|
||||
export names its kind as databook, traveler, csv or itp."""
|
||||
"""One print_log row and one audit row (action `print`, `export`, or `report exported` for the PM reports, which
|
||||
belong to no packet), in one transaction. The audit detail of an export names its kind as databook, traveler,
|
||||
csv or itp."""
|
||||
audit_detail: dict[str, Any] = {
|
||||
"kind": EXPORT_KINDS.get(kind, kind) if action == "export" else kind,
|
||||
"what": what,
|
||||
@@ -127,6 +128,9 @@ def record(
|
||||
audit_detail["asset_id"] = str(asset_id)
|
||||
if itp_id is not None:
|
||||
audit_detail["itp_id"] = itp_id
|
||||
subject = "itp" if itp_id is not None and packet_id is None else "packet"
|
||||
if action == "report exported":
|
||||
subject = "report"
|
||||
with conn.transaction():
|
||||
new_id = conn.execute(
|
||||
"INSERT INTO print_log (by, by_name, session_id, packet_id, unit_id, itp_id, kind, what, pages, asset_id,"
|
||||
@@ -153,7 +157,7 @@ def record(
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="itp" if itp_id is not None and packet_id is None else "packet",
|
||||
subject_type=subject,
|
||||
subject_id=itp_id if itp_id is not None and packet_id is None else packet_id,
|
||||
packet_id=packet_id,
|
||||
detail=audit_detail,
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
from .. import policy
|
||||
from ..text import LIMITS
|
||||
from . import itp, packets, qcdocs
|
||||
from . import itp, packets, qcdocs, search
|
||||
|
||||
A = policy.Action
|
||||
|
||||
@@ -128,6 +128,8 @@ LAST_SQL = (
|
||||
" WHERE h.packet_id = p.id ORDER BY h.id DESC LIMIT 1) h ON true"
|
||||
" WHERE p.removed_at IS NULL AND p.status::text = ANY(%s) AND h.result = %s"
|
||||
)
|
||||
WAITING_SQL = "SELECT count(*) FROM packets p WHERE p.removed_at IS NULL AND p.status = 'qc'"
|
||||
BOARD_LIMIT = 5000
|
||||
COUNT_SQL = (
|
||||
"SELECT count(DISTINCT l.packet_id) FROM packet_log l JOIN packets p ON p.id = l.packet_id"
|
||||
" WHERE l.at > now() - interval '7 days' AND l.action LIKE %s AND p.removed_at IS NULL"
|
||||
@@ -147,29 +149,54 @@ def _count_7d(conn: Any, prefix: str, where: str, params: list[Any]) -> int:
|
||||
|
||||
|
||||
def home(
|
||||
conn: Any, pp: policy.Principal, *, project_id: int | None = None, unit_id: int | None = None
|
||||
conn: Any,
|
||||
pp: policy.Principal,
|
||||
*,
|
||||
project_id: int | None = None,
|
||||
unit_id: int | None = None,
|
||||
filters: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""GET /api/qc/home: the KPIs, the three boards and the active project folders."""
|
||||
filters: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
|
||||
"""GET /api/qc/home: the KPIs, the three boards and the active project folders. `filters` (the find bar's)
|
||||
narrow the cards of the three boards; the KPIs follow the project and unit only."""
|
||||
scope: dict[str, Any] = {"project_id": project_id, "unit_id": unit_id}
|
||||
narrow = search.board_filters(filters or {})
|
||||
narrow.update(scope)
|
||||
ready = sorted(
|
||||
packets.list_cards(conn, pp, {**filters, "status": "qc"}),
|
||||
packets.list_cards(conn, pp, {**narrow, "status": "qc", "limit": BOARD_LIMIT}),
|
||||
key=lambda c: (c["status_at"], c["id"]),
|
||||
)
|
||||
where, params = _scope_where(project_id, unit_id)
|
||||
acc_ids = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)[:ACCEPTED_ON_BOARD]
|
||||
acc_all = _ids_with_last(conn, ("complete", "filed"), "accepted", where, params)
|
||||
if search.narrowing(narrow) and acc_all:
|
||||
# The newest accepted packets that match the find bar (a light pass, no `can`), then the board's eight.
|
||||
hits = {
|
||||
c["id"]
|
||||
for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_all, "lite": True, "limit": BOARD_LIMIT})
|
||||
}
|
||||
acc_all = [i for i in acc_all if i in hits]
|
||||
acc_ids = acc_all[:ACCEPTED_ON_BOARD]
|
||||
rej_ids = _ids_with_last(conn, ("review",), "rejected", where, params)
|
||||
by_id = {c["id"]: c for c in packets.list_cards(conn, pp, {"ids": acc_ids + rej_ids})} if acc_ids or rej_ids else {}
|
||||
by_id: dict[Any, dict[str, Any]] = {}
|
||||
if acc_ids or rej_ids:
|
||||
by_id = {
|
||||
c["id"]: c for c in packets.list_cards(conn, pp, {**narrow, "ids": acc_ids + rej_ids, "limit": BOARD_LIMIT})
|
||||
}
|
||||
accepted = [by_id[i] for i in acc_ids if i in by_id]
|
||||
rejected = [by_id[i] for i in rej_ids if i in by_id]
|
||||
_add_qc(conn, ready + accepted + rejected)
|
||||
waiting = len(ready)
|
||||
if search.narrowing(filters or {}):
|
||||
row = conn.execute(WAITING_SQL + where, params).fetchone() # where: fixed fragments from _scope_where
|
||||
waiting = int(row[0] or 0)
|
||||
return {
|
||||
"kpis": {
|
||||
"waiting": len(ready),
|
||||
"waiting": waiting,
|
||||
"accepted_7d": _count_7d(conn, "Q.C. accepted", where, params),
|
||||
"rejected_7d": _count_7d(conn, "Q.C. rejected", where, params),
|
||||
},
|
||||
"columns": {"ready": ready, "accepted": accepted, "rejected": rejected},
|
||||
"folders": qcdocs.folders(conn, "active"),
|
||||
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""The find bar's search and filter terms, shared by every list: the boards, GET /api/packets and the PM reports.
|
||||
|
||||
A port of the old filters.js (FLT_STATUS, FLT_FLAGS, FLT_FLAGKIND, fltHay, mineFilter, fltFn, FLT_SORT), applied by
|
||||
the server so a board never fetches more than it shows. Every term is SQL over CARD_SQL's aliases: p (packets),
|
||||
u (units), pr (projects), d (departments), st (stations) and wu (the assigned welder). Request text only ever goes
|
||||
in as a parameter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from .. import policy
|
||||
from ..auth.groups import SCREENS
|
||||
from ..text import clean_text
|
||||
|
||||
# ---------------------------------------------------------------- vocabulary
|
||||
|
||||
OPEN_STATUSES = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete")
|
||||
|
||||
# The find bar's status words. A raw status (or a comma list of them) keeps working.
|
||||
FLT_STATUS: dict[str, tuple[str, ...]] = {
|
||||
"draft": ("draft",),
|
||||
"warehouse": ("warehouse",),
|
||||
"ready": ("ready",),
|
||||
"shop": ("ready", "assigned", "in_progress", "review", "qc"),
|
||||
"station": ("assigned", "in_progress"),
|
||||
"review": ("review",),
|
||||
"qc": ("qc",),
|
||||
"complete": ("complete",),
|
||||
"filed": ("filed",),
|
||||
"open": OPEN_STATUSES,
|
||||
}
|
||||
FLT_STATUS_LABELS: dict[str, str] = {
|
||||
"draft": "Ready to release",
|
||||
"warehouse": "With warehouse (any stage)",
|
||||
"ready": "Ready to assign",
|
||||
"shop": "In the shop (ready to Q.C.)",
|
||||
"station": "At a station (assigned / working)",
|
||||
"review": "Supervisor review",
|
||||
"qc": "At Q.C.",
|
||||
"complete": "Accepted / routed",
|
||||
"filed": "Filed",
|
||||
"open": "Everything not filed",
|
||||
}
|
||||
|
||||
FLT_FLAGS = ("co", "short", "overdue", "week", "mine")
|
||||
FLT_FLAG_LABELS: dict[str, str] = {
|
||||
"co": "Change order open",
|
||||
"short": "Shorts open",
|
||||
"overdue": "Project overdue",
|
||||
"week": "Due within 7 days",
|
||||
"mine": "Mine",
|
||||
}
|
||||
|
||||
# The Flag select (drafts only): value -> rule name.
|
||||
FLT_FLAGKIND: dict[str, str] = {
|
||||
"any": "flagged",
|
||||
"rev": "rev_mismatch",
|
||||
"unconf": "rev_unconfirmed",
|
||||
"hand": "hand",
|
||||
"none": "not_released",
|
||||
"vault": "not_found",
|
||||
"read": "unreadable",
|
||||
"wait": "drawing_needed",
|
||||
"no": "not_flagged",
|
||||
}
|
||||
FLT_FLAGKIND_LABELS: dict[str, str] = {
|
||||
"any": "Flagged (any reason)",
|
||||
"rev": "Rev mismatch with M-Files",
|
||||
"unconf": "Rev not confirmed yet",
|
||||
"hand": "Made or uploaded by hand",
|
||||
"none": "Still in drafting",
|
||||
"vault": "Drawing not found in M-Files",
|
||||
"read": "Drawing couldn't be read",
|
||||
"wait": "Drawing being looked up",
|
||||
"no": "Not flagged",
|
||||
}
|
||||
# The old raw values (phase 2 to 7) that keep working.
|
||||
FLAG_ON = ("1", "true", "yes", "flagged")
|
||||
FLAG_OFF = ("0", "false")
|
||||
|
||||
# The status texts of the frontend's lib/packets.ts STATUS_LABEL (search matches these; the PM history groups by
|
||||
# them).
|
||||
STATUS_LABEL: dict[str, str] = {
|
||||
"draft": "Ready to release",
|
||||
"warehouse": "With warehouse",
|
||||
"ready": "Ready to assign",
|
||||
"assigned": "Sent to station",
|
||||
"in_progress": "Working",
|
||||
"review": "Supervisor review",
|
||||
"qc": "Q.C. inspection",
|
||||
"complete": "Routed",
|
||||
"filed": "Filed in data book",
|
||||
}
|
||||
|
||||
TIE = "p.updated_at DESC, p.id DESC"
|
||||
SORTS: dict[str, str] = {
|
||||
"updated": TIE,
|
||||
"project": 'pr.number COLLATE "C", u.lot COLLATE "C", p.op COLLATE "C", p.id',
|
||||
"due": f"coalesce(u.due, pr.due) ASC NULLS LAST, {TIE}",
|
||||
"status": f"p.status, {TIE}",
|
||||
"dept": f"p.dept_code, {TIE}",
|
||||
}
|
||||
SORT_LABELS: dict[str, str] = {
|
||||
"updated": "Last updated",
|
||||
"project": "Project / lot",
|
||||
"due": "Due date",
|
||||
"status": "Status",
|
||||
"dept": "Department",
|
||||
}
|
||||
|
||||
WORDS_MAX = 8
|
||||
WORD_MAX = 60
|
||||
# Words that match everything, so "lot 52" and "rev B" work as the old hay text did.
|
||||
WORDS_ALWAYS = ("lot", "rev")
|
||||
MINE_SCREENS = ("clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin")
|
||||
LEAD = 2
|
||||
|
||||
|
||||
def like(word: str) -> str:
|
||||
"""An ILIKE pattern for a word anywhere, with %, _ and the backslash escaped."""
|
||||
return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- status
|
||||
|
||||
|
||||
def status_terms(value: str) -> list[str]:
|
||||
"""The raw statuses a status value stands for: a find bar word expands, a raw status stays, in a comma list."""
|
||||
out: list[str] = []
|
||||
for part in str(value or "").split(","):
|
||||
s = part.strip().lower()
|
||||
if not s:
|
||||
continue
|
||||
for st in FLT_STATUS.get(s, (s,)):
|
||||
if st not in out:
|
||||
out.append(st)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- search
|
||||
|
||||
_STATUS_CASE = (
|
||||
"(CASE p.status::text "
|
||||
+ " ".join(f"WHEN '{k}' THEN '{v}'" for k, v in STATUS_LABEL.items())
|
||||
+ " ELSE p.status::text END)"
|
||||
)
|
||||
|
||||
# One fragment per field, each with exactly one %s (the word's pattern).
|
||||
_FIELDS: tuple[str, ...] = (
|
||||
"pr.number ILIKE %s",
|
||||
"pr.name ILIKE %s",
|
||||
"(pr.number || u.lot) ILIKE %s",
|
||||
"(pr.number || ltrim(u.lot, '0')) ILIKE %s",
|
||||
"u.lot ILIKE %s",
|
||||
"ltrim(u.lot, '0') ILIKE %s",
|
||||
"u.name ILIKE %s",
|
||||
"p.op ILIKE %s",
|
||||
"p.title ILIKE %s",
|
||||
"p.drawing ILIKE %s",
|
||||
"p.rev ILIKE %s",
|
||||
"p.wps ILIKE %s",
|
||||
"p.customer ILIKE %s",
|
||||
"pr.customer ILIKE %s",
|
||||
"pr.po ILIKE %s",
|
||||
"d.name ILIKE %s",
|
||||
"st.name ILIKE %s",
|
||||
"wu.display_name ILIKE %s",
|
||||
"p.welder_stamp ILIKE %s",
|
||||
"wu.welder_stamp ILIKE %s",
|
||||
_STATUS_CASE + " ILIKE %s",
|
||||
"array_to_string(p.flags, ' ') ILIKE %s",
|
||||
"EXISTS (SELECT 1 FROM project_aliases pa WHERE pa.project_id = pr.id"
|
||||
" AND (pa.unit_id IS NULL OR pa.unit_id = u.id) AND pa.alias ILIKE %s)",
|
||||
"EXISTS (SELECT 1 FROM packet_work_orders wo WHERE wo.packet_id = p.id"
|
||||
" AND (wo.wo_base || '/' || wo.lot || '/' || wo.split || '/' || wo.sub) ILIKE %s)",
|
||||
"EXISTS (SELECT 1 FROM bom_lines b WHERE b.packet_id = p.id"
|
||||
" AND concat_ws(' ', b.heat, b.part_id, b.description, b.material) ILIKE %s)",
|
||||
"EXISTS (SELECT 1 FROM change_orders c WHERE c.packet_id = p.id"
|
||||
" AND concat_ws(' ', 'CO-' || c.co_no, c.ecn) ILIKE %s)",
|
||||
"EXISTS (SELECT 1 FROM welds w LEFT JOIN users ww ON ww.id = w.welder_id WHERE w.packet_id = p.id"
|
||||
" AND w.removed_at IS NULL AND concat_ws(' ', w.stamp, ww.display_name) ILIKE %s)",
|
||||
)
|
||||
|
||||
|
||||
def search_words(q: str) -> list[str]:
|
||||
"""The words of a search: at most 8, each cut at 60 characters, with "lot" and "rev" left out."""
|
||||
words = [w[:WORD_MAX] for w in clean_text(q or "", 1000).split()[:WORDS_MAX]]
|
||||
return [w for w in words if w.lower() not in WORDS_ALWAYS]
|
||||
|
||||
|
||||
def search_terms(q: str) -> tuple[list[str], list[Any]]:
|
||||
"""One "(...)" term per word; every word must match some field. Child matches are EXISTS terms, so a packet
|
||||
is never listed twice."""
|
||||
from .packets import FLAG_SQL
|
||||
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
for word in search_words(q):
|
||||
pat = like(word)
|
||||
parts = list(_FIELDS)
|
||||
# The word "flagged" (or a part of it) finds every flagged draft, as the old hay text did.
|
||||
parts.append(f"({FLAG_SQL} AND 'flagged' ILIKE %s)")
|
||||
where.append("(" + " OR ".join(parts) + ")")
|
||||
params += [pat] * len(parts)
|
||||
return where, params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Mine
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MineScope:
|
||||
screen: str
|
||||
principal: policy.Principal
|
||||
station_id: int | None
|
||||
|
||||
|
||||
def screens_of(pp: policy.Principal) -> list[str]:
|
||||
"""The screens a policy principal may open, in SCREENS order (admin: every screen)."""
|
||||
if pp.admin:
|
||||
return list(SCREENS)
|
||||
have: set[str] = set()
|
||||
if "CL" in pp.support:
|
||||
have.add("clerical")
|
||||
if "WH" in pp.support:
|
||||
have.add("warehouse")
|
||||
if "QC" in pp.support:
|
||||
have.add("qc")
|
||||
if pp.pm:
|
||||
have.add("pm")
|
||||
for lvl in pp.shop.values():
|
||||
have.add("stall")
|
||||
if int(lvl) >= LEAD:
|
||||
have.add("supervisor")
|
||||
return [s for s in SCREENS if s in have]
|
||||
|
||||
|
||||
def mine_scope(pp: policy.Principal, screen: str | None, station_id: int | None) -> MineScope:
|
||||
"""The screen Mine is decided for: the one the caller names, else the first screen the roles give."""
|
||||
s = str(screen or "").strip().lower()
|
||||
if s not in MINE_SCREENS:
|
||||
found = screens_of(pp)
|
||||
s = found[0] if found else "pm"
|
||||
return MineScope(screen=s, principal=pp, station_id=station_id)
|
||||
|
||||
|
||||
def mine_term(m: MineScope) -> tuple[str | None, list[Any]]:
|
||||
"""The Mine rule (the old mineFilter) for the screen, or None when it is everything (PM, admin)."""
|
||||
pp = m.principal
|
||||
if m.screen == "clerical":
|
||||
return "(p.status = 'draft' OR (p.status = 'complete' AND p.routed_to = 'databook'))", []
|
||||
if m.screen == "warehouse":
|
||||
return (
|
||||
"(p.status = 'warehouse' OR EXISTS (SELECT 1 FROM bom_shorts ms WHERE ms.packet_id = p.id"
|
||||
" AND ms.closed_at IS NULL))",
|
||||
[],
|
||||
)
|
||||
if m.screen == "qc":
|
||||
return "p.status = 'qc'", []
|
||||
if m.screen == "supervisor":
|
||||
depts = sorted(code for code, lvl in pp.shop.items() if int(lvl) >= LEAD)
|
||||
if not depts:
|
||||
return (None, []) if pp.admin else ("false", [])
|
||||
return "p.dept_code = ANY(%s)", [depts]
|
||||
if m.screen == "stall":
|
||||
depts = sorted(pp.shop)
|
||||
if m.station_id is None:
|
||||
return "false", []
|
||||
if not depts and pp.admin:
|
||||
return "p.station_id = %s", [int(m.station_id)]
|
||||
return "(p.dept_code = ANY(%s) AND p.station_id = %s)", [depts, int(m.station_id)]
|
||||
return None, []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- quick flags and flag kinds
|
||||
|
||||
|
||||
def flag_terms(flags: Iterable[str], *, today: date, mine: MineScope | None) -> tuple[list[str], list[Any]]:
|
||||
"""The quick flag chips: every chip on must hold."""
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
for f in flags:
|
||||
if f == "co":
|
||||
where.append("EXISTS (SELECT 1 FROM v_open_cos fc WHERE fc.packet_id = p.id)")
|
||||
elif f == "short":
|
||||
where.append("EXISTS (SELECT 1 FROM bom_shorts fs WHERE fs.packet_id = p.id AND fs.closed_at IS NULL)")
|
||||
elif f == "overdue":
|
||||
where.append("coalesce(u.due, pr.due) < %s")
|
||||
params.append(today)
|
||||
elif f == "week":
|
||||
where.append("coalesce(u.due, pr.due) <= %s")
|
||||
params.append(today + timedelta(days=7))
|
||||
elif f == "mine" and mine is not None:
|
||||
term, p = mine_term(mine)
|
||||
if term is not None:
|
||||
where.append(term)
|
||||
params += p
|
||||
return where, params
|
||||
|
||||
|
||||
def _kind_term(kinds: list[str]) -> tuple[str, list[Any]]:
|
||||
"""Drafts carrying one of the flag kinds. A visual draft whose flags are empty but whose drawing state still
|
||||
names the problem counts too, the same way flag_of reads it."""
|
||||
from .packets import STATE_KINDS
|
||||
|
||||
states = [st for st, k in STATE_KINDS.items() if k in kinds]
|
||||
return (
|
||||
"(p.status = 'draft' AND (p.flags && %s::text[] OR (cardinality(p.flags) = 0 AND p.source_kind = 'visual'"
|
||||
" AND p.drawing_state::text = ANY(%s))))",
|
||||
[kinds, states],
|
||||
)
|
||||
|
||||
|
||||
def flagkind_term(value: str) -> tuple[str, list[Any]] | None:
|
||||
"""The Flag select's term, or None for no filter. The old raw values keep working."""
|
||||
from .packets import FLAG_SQL, HAND_UPLOAD_FLAGS, RX_FLAG_KIND
|
||||
|
||||
v = str(value or "").strip().lower()
|
||||
if not v or v == "all":
|
||||
return None
|
||||
if v == "any" or v in FLAG_ON:
|
||||
return FLAG_SQL, []
|
||||
if v == "no" or v in FLAG_OFF:
|
||||
return "NOT " + FLAG_SQL, []
|
||||
if v == "hand":
|
||||
return "(p.status = 'draft' AND (p.source_kind = 'hand' OR p.flags && %s::text[]))", [list(HAND_UPLOAD_FLAGS)]
|
||||
if v in ("hand_upload", "hand_uploads"):
|
||||
return "(p.status = 'draft' AND p.flags && %s::text[])", [list(HAND_UPLOAD_FLAGS)]
|
||||
if v == "unconf":
|
||||
# The drawing is attached but the drawing job has not confirmed the rev with M-Files yet (the read model
|
||||
# keeps that in visual->>'rev_confirmed').
|
||||
return (
|
||||
"(p.status = 'draft' AND p.source_kind = 'visual' AND p.drawing_state = 'attached'"
|
||||
" AND (p.visual->>'rev_confirmed') IS DISTINCT FROM 'true')",
|
||||
[],
|
||||
)
|
||||
if v == "rev":
|
||||
return _kind_term(["rev_mismatch"])
|
||||
if v == "none":
|
||||
return _kind_term(["not_released"])
|
||||
if v == "vault":
|
||||
return _kind_term(["not_found"])
|
||||
if v == "read":
|
||||
return _kind_term(["lookup_failed", "bad_rev"])
|
||||
if v == "wait":
|
||||
return _kind_term(["drawing_needed"])
|
||||
if RX_FLAG_KIND.match(v):
|
||||
return _kind_term([v])
|
||||
return "false", []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sort and facets
|
||||
|
||||
|
||||
def order_by(sort: str | None) -> str:
|
||||
return SORTS.get(str(sort or "").strip().lower(), TIE)
|
||||
|
||||
|
||||
# {scope} is fixed terms from _scope, never request text.
|
||||
FACET_STATIONS_SQL = """
|
||||
SELECT DISTINCT st.id, st.name, st.dept_code FROM packets p JOIN units u ON u.id = p.unit_id
|
||||
JOIN stations st ON st.id = p.station_id WHERE p.removed_at IS NULL{scope} ORDER BY st.name, st.id
|
||||
"""
|
||||
FACET_WORKERS_SQL = """
|
||||
SELECT DISTINCT wu.id, wu.display_name FROM packets p JOIN units u ON u.id = p.unit_id
|
||||
JOIN users wu ON wu.id = p.welder_id WHERE p.removed_at IS NULL{scope} ORDER BY wu.display_name, wu.id
|
||||
"""
|
||||
|
||||
|
||||
def _scope(project_id: int | None, unit_id: int | None, ids: list[int] | None) -> tuple[str, list[Any]]:
|
||||
terms = ""
|
||||
params: list[Any] = []
|
||||
if project_id is not None:
|
||||
terms += " AND u.project_id = %s"
|
||||
params.append(int(project_id))
|
||||
if unit_id is not None:
|
||||
terms += " AND p.unit_id = %s"
|
||||
params.append(int(unit_id))
|
||||
if ids is not None:
|
||||
terms += " AND p.id = ANY(%s)"
|
||||
params.append([int(i) for i in ids])
|
||||
return terms, params
|
||||
|
||||
|
||||
def facets(
|
||||
conn: Any, *, project_id: int | None = None, unit_id: int | None = None, ids: Iterable[int] | None = None
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""The distinct stations and assigned welders among the packets in the project and unit scope (and, for a
|
||||
board, among the board's own packets), ordered by name. The other filters do not narrow them."""
|
||||
from .packets import rows
|
||||
|
||||
scope, params = _scope(project_id, unit_id, None if ids is None else list(ids))
|
||||
stations = rows(conn, FACET_STATIONS_SQL.format(scope=scope), params)
|
||||
workers = rows(conn, FACET_WORKERS_SQL.format(scope=scope), params)
|
||||
return {
|
||||
"stations": [{"id": r["id"], "name": r["name"], "dept_code": r["dept_code"]} for r in stations],
|
||||
"workers": [{"id": str(r["id"]), "display_name": r["display_name"]} for r in workers],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the filters dict
|
||||
|
||||
|
||||
def _int_or_none(v: Any) -> int | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _uuid_text(v: Any) -> str | None:
|
||||
"""A welder id as uuid text; anything that is not a uuid becomes a value no packet has."""
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return str(uuid.UUID(s))
|
||||
except ValueError:
|
||||
return "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
def parse_flags(value: Any) -> list[str]:
|
||||
out: list[str] = []
|
||||
for part in str(value or "").split(","):
|
||||
f = part.strip().lower()
|
||||
if f in FLT_FLAGS and f not in out:
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
def parse_filters(
|
||||
params: Mapping[str, Any], pp: policy.Principal, *, screen: str | None, today: date | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""The filters dict list_cards takes, from the find bar's query values. `screen` is the default for Mine (the
|
||||
board's own screen); `mine_screen` in the params wins when given."""
|
||||
flags = parse_flags(params.get("flags"))
|
||||
mine_station = _int_or_none(params.get("mine_station_id"))
|
||||
mine = mine_scope(pp, str(params.get("mine_screen") or "") or screen, mine_station) if "mine" in flags else None
|
||||
out: dict[str, Any] = {
|
||||
"status": str(params.get("status") or "").strip(),
|
||||
"dept": (str(params.get("dept") or "").strip() or None),
|
||||
"unit_id": _int_or_none(params.get("unit_id")),
|
||||
"project_id": _int_or_none(params.get("project_id")),
|
||||
"q": clean_text(params.get("q") or "", 200),
|
||||
"flag": str(params.get("flag") or "").strip().lower(),
|
||||
"flags": flags,
|
||||
"station_id": _int_or_none(params.get("station_id")),
|
||||
"welder_id": _uuid_text(params.get("welder_id")),
|
||||
"sort": str(params.get("sort") or "updated").strip().lower(),
|
||||
"mine": mine,
|
||||
"today": today or date.today(),
|
||||
}
|
||||
if out["dept"] and out["dept"].lower() == "all":
|
||||
out["dept"] = None
|
||||
if out["status"].lower() == "all":
|
||||
out["status"] = ""
|
||||
if out["flag"] == "all":
|
||||
out["flag"] = ""
|
||||
return out
|
||||
|
||||
|
||||
def narrowing(filters: Mapping[str, Any]) -> bool:
|
||||
"""True when a filter other than the project and unit pick is set (the old fset)."""
|
||||
return bool(
|
||||
filters.get("q")
|
||||
or filters.get("status")
|
||||
or filters.get("also_status")
|
||||
or filters.get("dept")
|
||||
or filters.get("flag")
|
||||
or filters.get("flags")
|
||||
or filters.get("station_id") is not None
|
||||
or filters.get("welder_id")
|
||||
)
|
||||
|
||||
|
||||
def filter_where(filters: Mapping[str, Any]) -> tuple[list[str], list[Any]]:
|
||||
"""The find bar terms of a filters dict: q, the user's status (`also_status` on a board, whose own status is
|
||||
fixed), station, welder, the quick flags (Mine included) and the flag kind."""
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
also = status_terms(str(filters.get("also_status") or ""))
|
||||
if also:
|
||||
where.append("p.status::text = ANY(%s)")
|
||||
params.append(also)
|
||||
if filters.get("station_id") is not None:
|
||||
where.append("p.station_id = %s")
|
||||
params.append(int(filters["station_id"]))
|
||||
if filters.get("welder_id"):
|
||||
where.append("p.welder_id = %s::uuid")
|
||||
params.append(str(filters["welder_id"]))
|
||||
flags = list(filters.get("flags") or [])
|
||||
if flags:
|
||||
today = filters.get("today") or date.today()
|
||||
mine = filters.get("mine")
|
||||
w, p = flag_terms(flags, today=today, mine=mine if isinstance(mine, MineScope) else None)
|
||||
where += w
|
||||
params += p
|
||||
fk = flagkind_term(str(filters.get("flag") or ""))
|
||||
if fk is not None:
|
||||
where.append(fk[0])
|
||||
params += fk[1]
|
||||
w, p = search_terms(str(filters.get("q") or ""))
|
||||
where += w
|
||||
params += p
|
||||
return where, params
|
||||
|
||||
|
||||
def board_filters(filters: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The filters for a board whose columns fix their own statuses: the user's status moves to `also_status` and
|
||||
the project, unit and limit are left to the board."""
|
||||
out = dict(filters)
|
||||
out["also_status"] = out.pop("status", "") or ""
|
||||
out.pop("sort", None)
|
||||
return out
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
|
||||
from .. import policy
|
||||
from ..api.errors import ApiError
|
||||
from . import packets
|
||||
from . import packets, search
|
||||
|
||||
LEAD, SUPERVISOR = 2, 3
|
||||
LEVEL_NAMES = {1: "worker", 2: "lead", 3: "supervisor"}
|
||||
@@ -110,6 +110,15 @@ 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."""
|
||||
@@ -215,8 +224,12 @@ def board(
|
||||
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."""
|
||||
"""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)
|
||||
@@ -233,7 +246,8 @@ def board(
|
||||
{"depts": codes, "statuses": list(BOARD_STATUSES), "limit": BOARD_LIMIT, **fparams},
|
||||
)
|
||||
]
|
||||
cards = _cards(conn, pp, ids)
|
||||
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]] = []
|
||||
@@ -290,14 +304,42 @@ def board(
|
||||
}
|
||||
)
|
||||
|
||||
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),
|
||||
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),
|
||||
}
|
||||
return {"depts": depts, "kpis": kpis, "cos": cos, "columns": columns}
|
||||
|
||||
|
||||
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:
|
||||
@@ -333,8 +375,9 @@ def stations_for(conn: Any, pp: policy.Principal) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
|
||||
"""GET /api/shop/stall: the packets at one station. 403 when the station is not in the principal's list."""
|
||||
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:
|
||||
@@ -345,11 +388,13 @@ def stall(conn: Any, pp: policy.Principal, station_id: int) -> dict[str, Any]:
|
||||
" AND status IN ('assigned', 'in_progress', 'review') ORDER BY updated_at, id LIMIT %s",
|
||||
(int(station_id), BOARD_LIMIT),
|
||||
)
|
||||
cards = _cards(conn, pp, [int(r["id"]) for r in found])
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user