Phase 8: Project Management, find bar, presence and motion
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s

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:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+3 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, Field
from .. import audit
from .. import audit, events
from ..auth import devices, signin, stepup
from ..auth import directory as userdir
from ..auth import pin as pins
@@ -186,6 +186,7 @@ def _lock(d: Db, p: Principal, source: str) -> None:
detail={"source": source},
ip=p.ip,
)
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "locked"})
@router.post("/lock", status_code=204)
@@ -240,5 +241,6 @@ def unlock(
detail={"factor": body.factor},
ip=p.ip,
)
events.publish(conn, {"event": "session", "session_id": str(p.session_id), "action": "unlocked"})
p.session["locked_since"] = None
return me_payload(conn, p)
+8 -4
View File
@@ -14,7 +14,7 @@ from typing import Any
from fastapi import Depends, Request, Response
from psycopg.types.json import Jsonb
from .. import audit, db
from .. import audit, db, events
from ..auth import devices, sessions
from ..auth import directory as userdir
from ..auth import groups as groupmap
@@ -268,9 +268,13 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
conn.execute("UPDATE sessions SET hidden_since = NULL WHERE id = %s", (row["id"],))
if lock_source is not None:
with conn.transaction():
conn.execute(
"UPDATE sessions SET locked_since = %s WHERE id = %s AND locked_since IS NULL", (now, row["id"])
)
locked = conn.execute(
"UPDATE sessions SET locked_since = %s WHERE id = %s AND locked_since IS NULL RETURNING id",
(now, row["id"]),
).fetchone()
if locked is not None:
# Presence drops a locked session at once (events.EventHub handles the event).
events.publish(conn, {"event": "session", "session_id": str(row["id"]), "action": "locked"})
snap = row.get("roles_snapshot") or {}
audit.append(
conn,
+8 -3
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from ..auth.groups import SCREENS
from ..auth.groups import SCREEN_LABELS, SCREENS, role_label
from ..events import EventHub, stream
from .deps import resolve_standalone, settings_of
@@ -22,16 +22,21 @@ async def events(
packet_id: int | None = Query(None, ge=1, le=2**62),
) -> StreamingResponse:
"""The event stream. `packet_id` is the packet the page has open; presence carries it, so the packet page can
show who else is looking at the same packet. The client reopens the stream when the open packet changes."""
show who else is looking at the same packet. The client reopens the stream when the open packet changes.
Presence names the person with the label of the screen they have open (a screen their roles allow), else their
primary role. A locked session is left out of presence until it unlocks."""
# The session check runs on its own pooled connection, returned before the stream starts
p = await run_in_threadpool(resolve_standalone, request)
hub: EventHub = request.app.state.events
open_screen = screen if screen in SCREENS and screen in p.screens else None
sub = hub.subscribe(
session_id=p.session_id,
user_id=p.user_id,
display_name=p.user["display_name"],
screen=screen if screen in SCREENS and screen in p.screens else None,
screen=open_screen,
packet_id=packet_id,
role=SCREEN_LABELS[open_screen] if open_screen else role_label(p.roles),
locked=p.locked,
)
hello = {"session_id": str(p.session_id), "build": settings_of(request).ps_build}
return StreamingResponse(
+58 -19
View File
@@ -8,8 +8,10 @@ Every mutating answer is the full read model.
from __future__ import annotations
import uuid
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, Query, Request
from psycopg.types.json import Jsonb
@@ -18,7 +20,7 @@ from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import ncr as ncrs
from ..domain import packets, shop
from ..domain import packets, search, shop
from ..domain import qc as qc_dom
from ..domain.pipeline import (
Expect,
@@ -294,32 +296,69 @@ def _pp(d: Db, p: Principal) -> policy.Principal:
# ---------------------------------------------------------------- list and read
@router.get("")
def list_packets(
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
def site_today(request: Request) -> date:
"""Today in the site time zone (settings.site_tz), once per request."""
try:
tz: Any = ZoneInfo(settings_of(request).site_tz)
except (ZoneInfoNotFoundError, ValueError):
tz = UTC
return datetime.now(tz).date()
def find_query(
q: str = Query("", max_length=200),
status: str = Query("", max_length=200),
dept: str = Query("", max_length=10),
station_id: int | None = Query(None, ge=1, le=2**62),
welder_id: str = Query("", max_length=40),
flags: str = Query("", max_length=100),
flag: str = Query("", max_length=20),
sort: str = Query("", max_length=20),
mine_screen: str = Query("", max_length=20),
mine_station_id: int | None = Query(None, ge=1, le=2**62),
) -> dict[str, Any]:
"""The find bar's query values, shared by GET /api/packets, the boards and the PM reports."""
return {
"q": q,
"status": status,
"dept": dept,
"station_id": station_id,
"welder_id": welder_id,
"flags": flags,
"flag": flag,
"sort": sort,
"mine_screen": mine_screen,
"mine_station_id": mine_station_id,
}
FIND = Depends(find_query)
@router.get("")
def list_packets(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
unit_id: int | None = Query(None),
project_id: int | None = Query(None),
q: str = Query("", max_length=200),
flag: str = Query("", max_length=20),
co: str = Query("", max_length=20),
facets: int = Query(0, ge=0, le=1),
limit: int = Query(500, ge=1, le=2000),
removed: int = Query(0, ge=0, le=1),
) -> dict[str, Any]:
filters = {
"status": status,
"dept": dept.strip() or None,
"unit_id": unit_id,
"project_id": project_id,
"q": clean_text(q, 200),
"flag": flag,
"co": co.strip().lower() or None,
"limit": limit,
"removed": bool(removed) and p.is_admin,
}
return {"packets": packets.list_cards(d.conn, _pp(d, p), filters)}
"""The cards for a list. The find bar's filters are applied here; `facets=1` adds the stations and workers
among the packets in the project and unit scope."""
pp = _pp(d, p)
filters = search.parse_filters(
{**find, "unit_id": unit_id, "project_id": project_id}, pp, screen=None, today=site_today(request)
)
filters.update({"co": co.strip().lower() or None, "limit": limit, "removed": bool(removed) and p.is_admin})
out: dict[str, Any] = {"packets": packets.list_cards(d.conn, pp, filters)}
if facets:
out["facets"] = search.facets(d.conn, project_id=project_id, unit_id=unit_id)
return out
@router.post("", status_code=201, dependencies=WRITE)
+229
View File
@@ -0,0 +1,229 @@
"""Project Management: the read-only reports, the CSV exports and the production status PDF.
Any signed-in session reads (every role may open every packet, PM included). Nothing here writes a packet; an export
writes one print_log row and one audit row (`report exported`). Every endpoint takes the find bar's scope: the
project and unit pick, dept, status, station_id, welder_id, flags, flag, q, and the history period (30, 90, 365 or
all days; default 90). Mine is ignored here. The reports read PostgreSQL only.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import Response
from ..domain import packets, pm, prints
from ..pdf import pmreport
from ..pdf.tracecsv import to_csv
from .deps import Db, Principal, current_principal, get_db, settings_of
from .errors import ApiError
from .packets import FIND
router = APIRouter(prefix="/api/pm", tags=["pm"])
PERIOD = Query(pm.DEFAULT_PERIOD, pattern="^(30|90|365|all)$")
def _report(
request: Request,
d: Db,
p: Principal,
find: dict[str, Any],
project_id: int | None,
unit_id: int | None,
period: str,
expand: str | None = None,
) -> pm.Report:
conn = d.conn
pp = packets.policy_principal_for(conn, p)
s = settings_of(request)
return pm.load(
conn,
pp,
{**find, "project_id": project_id, "unit_id": unit_id},
now=datetime.now(UTC),
tz=s.site_tz,
period=period,
expand=expand,
)
@router.get("/overview")
def overview(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
) -> dict[str, Any]:
"""The KPIs, the plain-words summary, the pipeline and the units late or at risk."""
return pm.overview(_report(request, d, p, find, project_id, unit_id, period))
@router.get("/projects")
def projects(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
expand: str = Query("", max_length=2000),
) -> dict[str, Any]:
"""The projects table. Without `expand` only the picked project shows its units; `expand=all` opens every
project, a comma list of project ids opens those."""
rep = _report(request, d, p, find, project_id, unit_id, period, expand=expand)
return {"scope": rep.scope(), "tables": [pm.projects_table(rep)]}
@router.get("/aging")
def aging(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
) -> dict[str, Any]:
rep = _report(request, d, p, find, project_id, unit_id, period)
pipe = pm.pipeline(rep)
return {"scope": rep.scope(), "pipeline": pipe, "tables": pm.aging_tables(rep, pipe)}
@router.get("/hours")
def hours(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
) -> dict[str, Any]:
rep = _report(request, d, p, find, project_id, unit_id, period)
return {"scope": rep.scope(), "tables": pm.hours_tables(rep)}
@router.get("/quality")
def quality(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
) -> dict[str, Any]:
rep = _report(request, d, p, find, project_id, unit_id, period)
return {"scope": rep.scope(), "tables": pm.quality_tables(rep)}
@router.get("/find")
def find_jobs(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
) -> dict[str, Any]:
"""The cards in scope, in the find bar's sort (default last updated)."""
rep = _report(request, d, p, find, project_id, unit_id, period)
if (find.get("sort") or "updated") != "project":
f = {**rep.filters, "sort": find.get("sort") or "updated", "limit": pm.CARD_LIMIT, "lite": True}
cards = packets.list_cards(d.conn, packets.policy_principal_for(d.conn, p), f)
else:
cards = rep.cards
return {"scope": rep.scope(), "packets": cards}
@router.get("/export.csv")
def export_csv(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
table: str = Query("all", max_length=40),
) -> Response:
"""One table (by key), the jobs (`jobs`) or every report (`all`) as CSV for Excel: a BOM, CRLF lines and the
formula guard on every cell. Logged as `report exported`."""
rep = _report(request, d, p, find, project_id, unit_id, period)
found = pm.tables_for(rep, table.strip().lower())
if found is None:
raise ApiError(404, "not_found", "No such report table.")
name, tables = found
who = str(p.user.get("display_name") or p.user.get("sam_account") or "")
body = "\ufeff" + to_csv(pm.csv_rows(rep, name, tables, who)) + "\r\n"
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
filename = f"PacketStation_{pm.file_stem(name)}_{day}.csv"
label = rep.scope_label()
prints.record(
d.conn,
p,
action="report exported",
kind="report-csv",
what=f"{name} · CSV · {label}",
pages=None,
detail={"name": name, "format": "csv", "scope": label, "period": rep.period},
)
return Response(
content=body.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": prints.disposition(filename, download=True), "Cache-Control": "no-store"},
)
@router.get("/report.pdf")
def report_pdf(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
period: str = PERIOD,
download: int = Query(0, ge=0, le=1),
) -> Response:
"""The PESCO production status report (dark format), stamped as a controlled copy on every page. Inline unless
`download=1`. Logged as `report exported` with the page count."""
rep = _report(request, d, p, find, project_id, unit_id, period)
who = prints.who_of(p)
built = pmreport.build(
d.conn,
settings_of(request),
scope_params={**find, "project_id": project_id, "unit_id": unit_id, "period": period},
who=who,
now=rep.now,
report=rep,
)
label = rep.scope_label()
prints.record(
d.conn,
p,
action="report exported",
kind="report-pdf",
what=f"Production status report · PDF · {label}",
pages=built.pages,
stamp=built.stamp,
stamp_sig=built.stamp_sig,
detail={"name": "Production status report", "format": "pdf", "scope": label, "period": rep.period},
)
day = rep.now.astimezone(pm.zone(rep.tz)).date().isoformat()
return Response(
content=built.pdf,
media_type="application/pdf",
headers={
"Content-Disposition": prints.disposition(f"PESCO_Production_Status_{day}.pdf", download=bool(download)),
"Cache-Control": "no-store",
},
)
+12 -4
View File
@@ -10,18 +10,18 @@ from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from .. import audit, events, policy
from ..domain import assets as asset_store
from ..domain import itp as itp_dom
from ..domain import packets, qcdocs
from ..domain import packets, qcdocs, search
from ..domain import qc as dom
from ..text import clean_opt, clean_text
from .deps import Db, Principal, current_principal, get_db
from .errors import ApiError
from .packets import WRITE
from .packets import FIND, WRITE, site_today
router = APIRouter(prefix="/api/qc", tags=["qc"])
@@ -42,13 +42,21 @@ class QcDocBody(BaseModel):
@router.get("/home")
def home(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
"""The Q.C. home. The find bar values narrow the cards of the three boards; the KPIs follow the project and
unit only."""
conn = d.conn
return dom.home(conn, packets.policy_principal_for(conn, p), project_id=project_id, unit_id=unit_id)
pp = packets.policy_principal_for(conn, p)
filters = search.parse_filters(
{**find, "project_id": project_id, "unit_id": unit_id}, pp, screen="qc", today=site_today(request)
)
return dom.home(conn, pp, project_id=project_id, unit_id=unit_id, filters=filters)
@router.get("/folders")
+23 -5
View File
@@ -8,26 +8,37 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from ..domain import packets, shop
from ..domain import packets, search, shop
from ..text import clean_text
from .deps import Db, Principal, current_principal, get_db
from .packets import FIND, site_today
router = APIRouter(prefix="/api/shop", tags=["shop"])
@router.get("/board")
def board(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
dept: str = Query("", max_length=10),
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
) -> dict[str, Any]:
"""The supervisor board. `dept` picks one of the departments the person leads (it scopes the board and its
KPIs); the other find bar values narrow the cards."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
return shop.board(conn, pp, dept=clean_text(dept, 10) or None, project_id=project_id, unit_id=unit_id)
dept = clean_text(find.get("dept") or "", 10) or None
filters = search.parse_filters(
{**find, "dept": None, "project_id": project_id, "unit_id": unit_id},
pp,
screen="supervisor",
today=site_today(request),
)
return shop.board(conn, pp, dept=dept, project_id=project_id, unit_id=unit_id, filters=filters)
@router.get("/stations")
@@ -41,9 +52,16 @@ def stations(
@router.get("/stall")
def stall(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
station_id: int = Query(ge=1),
find: dict[str, Any] = FIND,
) -> dict[str, Any]:
"""The cards at one station. The find bar values narrow them; `station_id` here is the stall's station, so the
Station filter does not apply. Mine is the station's own work."""
conn = d.conn
return shop.stall(conn, packets.policy_principal_for(conn, p), station_id)
pp = packets.policy_principal_for(conn, p)
raw = {**find, "station_id": None, "mine_station_id": find.get("mine_station_id") or station_id}
filters = search.parse_filters(raw, pp, screen="stall", today=site_today(request))
return shop.stall(conn, pp, station_id, filters)
+18 -12
View File
@@ -12,9 +12,9 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, Query, Request
from ..domain import packets
from ..text import clean_text
from ..domain import packets, search
from .deps import Db, Principal, current_principal, get_db, settings_of
from .packets import FIND
router = APIRouter(prefix="/api/warehouse", tags=["warehouse"])
@@ -141,21 +141,22 @@ def board(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
q: str = Query("", max_length=200),
find: dict[str, Any] = FIND,
project_id: int | None = Query(None),
unit_id: int | None = Query(None),
dept: str = Query("", max_length=10),
) -> dict[str, Any]:
"""The warehouse board. The find bar values narrow the cards of every column and the shorts list; the KPIs
follow the project and unit only."""
conn = d.conn
pp = packets.policy_principal_for(conn, p)
tz = _tz(request)
filters: dict[str, Any] = {
"q": clean_text(q, 200),
"project_id": project_id,
"unit_id": unit_id,
"dept": dept.strip() or None,
"limit": BOARD_LIMIT,
}
parsed = search.parse_filters(
{**find, "project_id": project_id, "unit_id": unit_id},
pp,
screen="warehouse",
today=datetime.now(tz).date(),
)
filters: dict[str, Any] = {**search.board_filters(parsed), "limit": BOARD_LIMIT}
columns: dict[str, list[dict[str, Any]]] = {st: [] for st in (*STAGES, "released")}
for card in packets.list_cards(conn, pp, {**filters, "status": "warehouse"}):
@@ -182,4 +183,9 @@ def board(
for sr in packets.rows(conn, SHORTS_SQL, (list(by_id),)):
shorts.append(_short_row(sr, by_id[int(sr["packet_id"])], today, now))
return {"kpis": _kpis(conn, filters), "columns": columns, "shorts": shorts}
return {
"kpis": _kpis(conn, filters),
"columns": columns,
"shorts": shorts,
"facets": search.facets(conn, project_id=project_id, unit_id=unit_id),
}
+38
View File
@@ -228,6 +228,44 @@ def screens_for(roles: Iterable[str]) -> list[str]:
return [s for s in SCREENS if s in have]
# The label of a screen, shown beside a name in presence ("Also in this packet now: Ann (Q.C.)").
SCREEN_LABELS: dict[str, str] = {
"clerical": "Clerical",
"warehouse": "Warehouse",
"supervisor": "Supervisor",
"stall": "Weld Stall",
"qc": "Q.C.",
"pm": "Project Mgmt",
"admin": "Admin",
}
def _role_text(role: str) -> str:
if role == "admin":
return "Admin"
if role.startswith("clerical"):
return "Clerical"
if role.startswith("warehouse"):
return "Warehouse"
if role.startswith("qc"):
return "Q.C."
if role == "pm":
return "Project Mgmt"
if role.startswith("shop:"):
return "Supervisor" if role.endswith((":lead", ":supervisor")) else "Weld Stall"
return ""
def role_label(roles: Iterable[str]) -> str:
"""The person's primary role as a screen label: the first role in map order (admin, Clerical, Warehouse, Q.C.,
PM, then the shop departments). Empty when there is none."""
for r in _default.sort_roles(roles):
text = _role_text(r)
if text:
return text
return ""
def is_admin(roles: Iterable[str]) -> bool:
return "admin" in set(roles)
+15 -46
View File
@@ -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
+8 -4
View File
@@ -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,
+35 -8
View File
@@ -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),
}
+522
View File
@@ -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
+58 -13
View File
@@ -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),
}
+61 -7
View File
@@ -2,7 +2,9 @@
A thread holds a sync psycopg connection (app role) doing LISTEN ps_events and pushes each NOTIFY payload
to the event loop with call_soon_threadsafe. It reconnects with backoff when the database drops. Presence is
kept in process (one uvicorn worker); phase 8 completes it.
kept in process (one uvicorn worker): one entry per (user, screen, packet) with the display name and the role
label. A locked session drops out of presence until it is unlocked; a revoked one drops out at once. A browser
that goes away drops out when its stream ends (the stream's `finally`, at the latest on the next ping).
"""
from __future__ import annotations
@@ -15,6 +17,7 @@ import threading
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
log = logging.getLogger(__name__)
@@ -44,9 +47,17 @@ class Subscriber:
display_name: str
screen: str | None = None
packet_id: int | None = None # the packet the subscriber has open, for "Also here" on the packet page
role: str = "" # the label shown beside the name: the screen open, else the person's primary role
since: datetime = field(default_factory=lambda: datetime.now(UTC))
locked: bool = False # the session is locked: left out of presence until it is unlocked
revoked: bool = False # the session was revoked: left out of presence while the stream winds down
id: str = field(default_factory=lambda: uuid.uuid4().hex)
closed: bool = False
@property
def present(self) -> bool:
return not (self.closed or self.locked or self.revoked)
def put(self, item: Item) -> None:
"""Called on the event loop."""
try:
@@ -74,6 +85,8 @@ class EventHub:
display_name: str,
screen: str | None = None,
packet_id: int | None = None,
role: str = "",
locked: bool = False,
) -> Subscriber:
"""Register a subscriber on the running loop and broadcast presence."""
sub = Subscriber(
@@ -84,6 +97,8 @@ class EventHub:
display_name=display_name,
screen=screen,
packet_id=packet_id,
role=role,
locked=locked,
)
with self._lock:
self._subs[sub.id] = sub
@@ -97,17 +112,37 @@ class EventHub:
if removed is not None:
self._broadcast_presence()
def presence(self) -> dict[str, Any]:
def _users(self) -> list[dict[str, Any]]:
"""One entry per (user, screen, packet) among the present subscribers, the earliest `since` kept."""
with self._lock:
subs = list(self._subs.values())
subs = [s for s in self._subs.values() if s.present]
seen: dict[tuple[str, str | None, int | None], dict[str, Any]] = {}
for s in subs:
for s in sorted(subs, key=lambda x: x.since):
seen.setdefault(
(s.user_id, s.screen, s.packet_id),
{"user_id": s.user_id, "display_name": s.display_name, "screen": s.screen, "packet_id": s.packet_id},
{
"user_id": s.user_id,
"display_name": s.display_name,
"role": s.role,
"screen": s.screen,
"packet_id": s.packet_id,
"since": s.since.isoformat(),
},
)
users = sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
return {"users": users}
return sorted(seen.values(), key=lambda u: (u["display_name"].lower(), u["screen"] or "", u["packet_id"] or 0))
def presence(self) -> dict[str, Any]:
"""{users: [{user_id, display_name, role, screen, packet_id, since}]}, locked and revoked sessions left
out."""
return {"users": self._users()}
def presence_by_screen(self) -> dict[str, list[dict[str, Any]]]:
"""The same users grouped by the screen they have open ("none" for a stream without a screen), for the
Admin "who's in" view."""
out: dict[str, list[dict[str, Any]]] = {}
for u in self._users():
out.setdefault(u["screen"] or "none", []).append(u)
return out
def _send(self, sub: Subscriber, item: Item) -> None:
try:
@@ -134,11 +169,30 @@ class EventHub:
name = str(data.pop("event", "changed"))
with self._lock:
subs = list(self._subs.values())
changed = False
if name == "session":
target = str(data.get("session_id"))
subs = [s for s in subs if s.session_id == target]
changed = self._mark(subs, str(data.get("action") or ""))
for s in subs:
self._send(s, (name, data))
if changed:
self._broadcast_presence()
def _mark(self, subs: list[Subscriber], action: str) -> bool:
"""Apply a session event to that session's subscribers. True when presence changed."""
changed = False
with self._lock:
for s in subs:
before = s.present
if action == "locked":
s.locked = True
elif action == "unlocked":
s.locked = False
elif action == "revoked":
s.revoked = True
changed = changed or before != s.present
return changed
# ------------------------------------------------ listener thread
+2
View File
@@ -105,6 +105,7 @@ def create_app(
jobs,
packet_children,
packets,
pm,
prints,
projects,
qc,
@@ -133,6 +134,7 @@ def create_app(
shop,
qc,
itp,
pm,
):
app.include_router(module.router)
app.include_router(assets.blob_router)
+2 -1
View File
@@ -1,4 +1,5 @@
"""PDF output: the data book, the job traveler, the ITP, the printed copies, their stamps and metadata.
"""PDF output: the data book, the job traveler, the ITP, the printed copies, the PM production status report
(pmreport), their stamps and metadata.
Pure builders: they read through the connection they are given and the stored asset files, and return bytes. They
never write to the database and never log a print; the endpoints and the filing pipeline do that.
+12 -6
View File
@@ -45,10 +45,12 @@ from .style import (
R,
WhoRef,
clean,
fmt_clock,
fmt_clock_hhmm,
fmt_clock_hms,
fmt_d,
fmt_dt,
fmt_num,
round_half_up,
site_tz,
utc_now,
)
@@ -164,7 +166,7 @@ def co_summary(co: Mapping[str, Any]) -> str:
added = sum(1 for i in items if i.get("added"))
if added:
lines.append(f"{added} sheet{'s' if added > 1 else ''} added")
return ", ".join(lines) or str(co.get("reason") or "") or "documents changed"
return ", ".join(lines) or "documents changed" # the old coSummary(): the reason has its own column
class Book:
@@ -268,7 +270,7 @@ class Book:
w, h = float(page.mediabox.width), float(page.mediabox.height)
if int(page.get("/Rotate", 0) or 0) % 180 == 90:
w, h = h, w
return f'{round(w / 72)}" x {round(h / 72)}"'
return f'{round_half_up(w / 72)}" x {round_half_up(h / 72)}"'
except Exception as e: # an unreadable sheet is reported in section 1; try the next one
log.debug("sheet size not read: %s", type(e).__name__)
continue
@@ -428,6 +430,10 @@ class Book:
recs = list(closed)
if p.get("stage"):
recs.append(dict(p["stage"], current=True))
elif recs:
# Q.C. accept closes the stage here; the old build kept it as the packet's live record, which shows
# the work order target beside the time. The last closed stage plays that part.
recs[-1]["last"] = True
if not recs:
recs.append(
{
@@ -483,8 +489,8 @@ class Book:
stamp = welder.get("stamp") if isinstance(welder, Mapping) else None
wname = parts.name_of(self.users, welder) if welder else ""
clock = rec.get("clock") or {}
time = fmt_clock(clock.get("elapsed_ms"))
if rec.get("current"):
time = fmt_clock_hms(clock.get("elapsed_ms"))
if rec.get("current") or rec.get("last"):
time += f" (target {fmt_num(p.get('target_hrs') or 0)} h)"
rows: list[tuple[str, Any]] = [
("Department", name),
@@ -869,7 +875,7 @@ class Book:
(str(len(welds)), "Welds mapped"),
(f"{traced}/{len(bom)}", "BOM heats traced"),
(str(q.get("result") or "open").upper(), "Q.C. result"),
(fmt_clock(elapsed), "Weld time (h:mm)"),
(fmt_clock_hhmm(elapsed), "Weld time (h:mm)"),
]
kw = (CW - 3 * 16) / 4
for i, (n, label) in enumerate(figures):
+8 -4
View File
@@ -31,6 +31,11 @@ SPACE_LIMIT = 740.0
LINE_FACTOR = 1.15
PAD_V = 4.2
PAD_H = 4.0
# The old tables wrapped cells with jsPDF's splitTextToSize. autotable gives it the cell width less the padding plus
# 1 pt, and jsPDF's Helvetica widths run about 0.8 % narrower than the AFM widths reportlab measures with. Both are
# kept here so a cell breaks where the old book broke it ("10/18/2026 11:32 AM" stays on one line in 86 pt).
WRAP_SLACK = 1.0
JSPDF_WIDTH = 0.992
def rgb(c: Sequence[int]) -> Color:
@@ -348,7 +353,8 @@ class Doc:
) -> tuple[list[str], str, float]:
font = self._font(cell, spec)
sz = float(cell.get("size", spec.get("size", size)))
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), font, sz
room = (width - 2 * PAD_H + WRAP_SLACK) / JSPDF_WIDTH
return split_lines(cell.get("content"), font, sz, room), font, sz
def _row_height(
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
@@ -372,9 +378,7 @@ class Doc:
) -> None:
c = self.c
for col, span, cell in self._cells(row):
spec = {} if head else cols.get(col, {})
if head and col in cols and cols[col].get("align"):
spec = {"align": cols[col]["align"]}
spec = {} if head else cols.get(col, {}) # autotable column styles style the body only: heads sit left
x = L + sum(widths[:col])
w = sum(widths[col : col + span])
if cell.get("fill") is not None:
+444
View File
@@ -0,0 +1,444 @@
"""The PESCO production status report: the old pmPdf, report format C (dark).
Letter, portrait: a cover with the headline numbers, PART 01 SUMMARY (the plain-words lines and the pipeline bars),
PART 02 PROJECTS (every unit), PART 03 FLOW (the aging tables), PART 04 LABOR (the hours tables) and PART 05 ISSUES
(the quality tables). Every page carries the PESCO foot, CONFIDENTIAL & PROPRIETARY and its number, and the
controlled-copy stamp. Everything comes from domain/pm.py; nothing here reads VISUAL or writes the database.
Drawn on a reportlab Canvas directly. Every y here is measured from the top of the page, as jsPDF did. Text always
goes through `clean`, and cells are wrapped with simpleSplit on plain strings, so no markup is interpreted.
"""
from __future__ import annotations
import contextlib
import io
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from reportlab.lib.colors import Color
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from ..domain import pm
from . import stamps
from .style import WhoRef, clean, who_text
W, H, M = 612.0, 792.0, 40.0
CW = W - 2 * M
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
# The old pmPdf colour map.
C: dict[str, tuple[int, int, int]] = {
"bg": (13, 22, 27),
"panel": (26, 37, 43),
"alt": (22, 41, 49),
"cyan": (73, 192, 222),
"pink": (229, 88, 159),
"amber": (231, 179, 60),
"white": (255, 255, 255),
"near": (239, 245, 248),
"body": (169, 186, 195),
"muted": (119, 135, 143),
"sub": (191, 228, 240),
"line": (38, 54, 62),
"zebra": (17, 29, 35),
}
TABLE_TOP = 70.0
TABLE_BOTTOM = 40.0
BODY_SIZE = 7.2
HEAD_SIZE = 6.8
PAD = 3.2
LINE_FACTOR = 1.15
@dataclass(frozen=True)
class Built:
pdf: bytes
pages: int
stamp: str
stamp_sig: str | None
def rgb(c: Sequence[int]) -> Color:
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
def font_of(bold: bool = False) -> str:
return "Helvetica-Bold" if bold else "Helvetica"
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
out: list[str] = []
for part in clean(text).split("\n"):
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
return out or [""]
@lru_cache(maxsize=1)
def _logo() -> ImageReader | None:
try:
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
except OSError:
return None
class Pages:
"""The report canvas. Pages are numbered as they start; each gets the dark background and the foot."""
def __init__(self) -> None:
self.buf = io.BytesIO()
self.c = Canvas(self.buf, pagesize=(W, H), pageCompression=1)
self.page = 1
self.bg()
# ------------------------------------------------------------ primitives
def Y(self, y: float) -> float:
return H - y
def fill(self, color: Sequence[int]) -> None:
self.c.setFillColor(rgb(color))
def rect(self, x: float, y: float, w: float, h: float, color: Sequence[int], *, radius: float = 0) -> None:
self.fill(color)
if radius:
self.c.roundRect(x, self.Y(y + h), w, h, radius, stroke=0, fill=1)
else:
self.c.rect(x, self.Y(y + h), w, h, stroke=0, fill=1)
def text(
self,
s: Any,
x: float,
y: float,
*,
size: float = 9.5,
bold: bool = False,
color: Sequence[int] = C["body"],
align: str = "left",
max_width: float | None = None,
cs: float = 0.0,
) -> int:
"""jsPDF's text(): `y` is the first baseline; with max_width the text wraps. Returns the line count."""
font = font_of(bold)
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
self.fill(color)
for i, ln in enumerate(lines):
yy = self.Y(y + i * size * LINE_FACTOR)
width = stringWidth(ln, font, size) + cs * len(ln)
xx = x - width if align == "right" else x - width / 2 if align == "center" else x
t = self.c.beginText(xx, yy)
t.setFont(font, size)
t.setCharSpace(cs)
t.textOut(ln)
self.c.drawText(t)
return len(lines)
# ------------------------------------------------------------ pages
def bg(self) -> None:
self.rect(0, 0, W, H, C["bg"])
def foot(self) -> None:
self.text("PESCO · PROCESS EQUIPMENT & SERVICE COMPANY, INC.", M, H - 22, size=7, color=C["muted"])
self.text("CONFIDENTIAL & PROPRIETARY", W / 2, H - 22, size=7, color=C["amber"], align="center", bold=True)
self.text(str(self.page), W - M, H - 22, size=7, color=C["muted"], align="right")
def new_page(self) -> None:
self.c.showPage()
self.page += 1
self.bg()
self.foot()
def body(self, eyebrow: str, title: str, stand: str) -> float:
"""A body page: the eyebrow, "Production status report", a rule, the title and the stand-first."""
self.new_page()
self.text(eyebrow, M, 44, size=7.6, color=C["cyan"], bold=True, cs=1.2)
self.text("Production status report", W - M, 44, size=7.6, color=C["muted"], align="right")
self.c.setStrokeColor(rgb(C["line"]))
self.c.setLineWidth(0.6)
self.c.line(M, self.Y(52), W - M, self.Y(52))
self.text(title, M, 82, size=21, bold=True, color=C["near"])
if stand:
self.text(stand, M, 100, size=9.5, color=C["body"], max_width=CW)
return 118.0 if stand else 100.0
def finish(self) -> bytes:
self.c.showPage()
self.c.save()
return self.buf.getvalue()
# ------------------------------------------------------------ tables
def _widths(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> list[float]:
n = len(head)
natural = [2 * PAD + 10.0] * n
minw = [2 * PAD + 10.0] * n
rows: list[tuple[Sequence[str], str, float]] = [(head, font_of(True), HEAD_SIZE)]
rows += [(r, font_of(), BODY_SIZE) for r in body]
for cells, font, size in rows:
for i in range(min(n, len(cells))):
for part in clean(cells[i]).split("\n"):
natural[i] = max(natural[i], stringWidth(part, font, size) + 2 * PAD)
for word in part.split():
minw[i] = max(minw[i], min(stringWidth(word, font, size) + 2 * PAD, CW / 3))
total = sum(natural)
if total <= CW:
return [w * CW / total for w in natural]
base = sum(minw)
if base >= CW:
return [w * CW / base for w in minw]
extra = [natural[i] - minw[i] for i in range(n)]
spare = sum(extra) or 1.0
return [minw[i] + (CW - base) * extra[i] / spare for i in range(n)]
def _row_height(self, cells: Sequence[str], widths: Sequence[float], font: str, size: float) -> float:
lines = max(
len(split_lines(cells[i] if i < len(cells) else "", font, size, widths[i] - 2 * PAD))
for i in range(len(widths))
)
return lines * size * LINE_FACTOR + 2 * PAD
def _draw_row(
self,
cells: Sequence[str],
widths: Sequence[float],
y: float,
h: float,
*,
font: str,
size: float,
color: Sequence[int],
fill: Sequence[int],
) -> None:
x = M
self.c.setStrokeColor(rgb(C["line"]))
self.c.setLineWidth(0.4)
for i, w in enumerate(widths):
self.fill(fill)
self.c.rect(x, self.Y(y + h), w, h, stroke=1, fill=1)
lines = split_lines(cells[i] if i < len(cells) else "", font, size, w - 2 * PAD)
self.fill(color)
self.c.setFont(font, size)
for j, ln in enumerate(lines):
self.c.drawString(x + PAD, self.Y(y + PAD + j * size * LINE_FACTOR + size * 0.85), ln)
x += w
def table(self, t: Mapping[str, Any], start_y: float) -> float:
"""The old autoTable look: the head in cyan on the alt fill, zebra rows, flagged rows in pink, project rows
bold. The head repeats on every page. Returns the y under the table."""
head = [str(h) for h in t["head"]]
rows = list(t["rows"]) or [{"cells": ["Nothing in scope.", *[""] * (len(head) - 1)]}]
body = [[str(c) for c in r["cells"]] for r in rows]
widths = self._widths(head, body)
limit = H - TABLE_BOTTOM
is_projects = t.get("key") == "projects"
head_h = self._row_height(head, widths, font_of(True), HEAD_SIZE)
def draw_head(yy: float) -> float:
self._draw_row(head, widths, yy, head_h, font=font_of(True), size=HEAD_SIZE, color=C["cyan"], fill=C["alt"])
return yy + head_h
y = start_y
first = self._row_height(body[0], widths, font_of(), BODY_SIZE)
if y + head_h + first > limit:
self.new_page()
y = TABLE_TOP
y = draw_head(y)
for i, (r, cells) in enumerate(zip(rows, body, strict=True)):
bold = is_projects and r.get("lvl") == 0 and r.get("project_id") is not None
font = font_of(bold)
h = self._row_height(cells, widths, font, BODY_SIZE)
if y + h > limit:
self.new_page()
y = draw_head(TABLE_TOP)
color = C["pink"] if r.get("flag") else C["near"] if bold else C["body"]
fill = C["zebra"] if i % 2 == 1 else C["bg"]
self._draw_row(cells, widths, y, h, font=font, size=BODY_SIZE, color=color, fill=fill)
y += h
return y
def section(self, eyebrow: str, title: str, stand: str, tables: Sequence[Mapping[str, Any]]) -> None:
y = self.body(eyebrow, title, stand)
for t in tables:
if y > H - 140:
y = self.body(eyebrow, title + " (continued)", "")
self.text(t["title"], M, y + 6, size=11, bold=True, color=C["near"])
if t.get("note"):
self.text(t["note"], M, y + 20, size=7.8, color=C["muted"], max_width=CW)
y = self.table(t, y + (30 if t.get("note") else 16)) + 22
# ---------------------------------------------------------------- the pages
def _cover(r: Pages, rep: pm.Report, kpis: list[dict[str, Any]], who: WhoRef | None) -> None:
for i in range(60):
t = i / 60
r.rect(0, i * 4.4, W, 4.6, (round(15 + 10 * (1 - t)), round(48 + 20 * (1 - t)), round(58 + 22 * (1 - t))))
logo = _logo()
if logo is not None:
# A logo that does not draw is left out, as the old build did.
with contextlib.suppress(Exception):
r.c.drawImage(logo, M, r.Y(46 + 34), width=150, height=34, mask="auto", preserveAspectRatio=True)
r.text("PRODUCTION STATUS REPORT", M, 150, size=30, bold=True, color=C["white"])
r.text(f"Packet Station · {rep.scope_label()}", M, 176, size=12, color=C["sub"], max_width=CW)
for i in range(100):
t = i / 100
col = (round(73 + (229 - 73) * t), round(192 + (88 - 192) * t), round(222 + (159 - 222) * t))
r.rect(M + i * CW / 100, 196, CW / 100 + 0.5, 3, col)
cw = (CW - 3 * 10) / 4
for i, k in enumerate(kpis[:8]):
cx = M + (i % 4) * (cw + 10)
cy = 300 + (i // 4) * 96
hot = bool(k["alert"]) and str(k["n"]) != "0"
r.rect(cx, cy, cw, 84, C["panel"], radius=6)
r.rect(cx, cy, cw, 3, C["pink"] if hot else C["cyan"])
r.text(str(k["n"]), cx + 12, cy + 44, size=30, bold=True, color=C["white"])
r.text(
str(k["label"]).upper(),
cx + 12,
cy + 68,
size=7.2,
bold=True,
color=C["pink"] if hot else C["cyan"],
max_width=cw - 20,
)
meta = [
("Prepared by", str(who["display_name"]) if who and who.get("display_name") else who_text(who)),
("Source", "Packet Station live data"),
("History period", rep.period_label),
("Issued", pm.fmt_dt(rep.now, rep.tz)),
]
for i, (a, b) in enumerate(meta):
x = M + i * (CW / 4)
r.text(a.upper(), x, 560, size=7, color=C["muted"], bold=True, cs=0.6)
r.text(b, x, 575, size=9.5, color=C["near"], max_width=CW / 4 - 8)
r.text(
"Live snapshot of the shop's job packets at the time of issue. Figures follow the scope shown above.",
M,
610,
size=8.5,
color=C["muted"],
max_width=CW,
)
r.foot()
def _summary(r: Pages, lines: list[str], pipe: list[dict[str, Any]]) -> None:
y = r.body("PART 01 · SUMMARY", "Where things stand", "The short version first; the detail follows.")
for ln in lines:
r.fill(C["cyan"])
r.c.circle(M + 4, r.Y(y - 3), 2, stroke=0, fill=1)
n = r.text(ln, M + 14, y, size=10, color=C["near"], max_width=CW - 14)
y += n * 13 + 6
y += 10
top = max(1, *(s["count"] for s in pipe))
slow = next((s for s in pipe if s["slowest"]), None)
r.rect(M, y, CW, 34 + len(pipe) * 22, C["alt"], radius=6)
r.text("Open packets by step", M + 14, y + 20, size=10.5, bold=True, color=C["white"])
r.text(
"bar = packets in the step · label = average time there",
W - M - 14,
y + 20,
size=7.5,
color=C["muted"],
align="right",
)
y += 36
track = CW - 190 - 110
for s in pipe:
is_slow = s is slow
r.text(s["label"], M + 14, y + 9, size=8.2, color=C["body"])
bx = M + 190
r.rect(bx, y + 1, track, 10, C["line"])
if s["count"]:
r.rect(bx, y + 1, max(2.0, track * s["count"] / top), 10, C["pink"] if is_slow else C["cyan"])
label = f"{s['count']}" + (f" · avg {pm.fmt_days(s['avg_s'])}" if s["count"] else "")
label += " · slowest" if is_slow else ""
r.text(label, W - M - 104, y + 9, size=8, color=C["pink"] if is_slow else C["near"], bold=is_slow)
y += 22
y += 12
r.rect(M, y, CW, 40, C["panel"])
r.rect(M, y, 3, 40, C["cyan"])
r.text("What this says:", M + 12, y + 16, size=9.5, bold=True, color=C["white"])
says = (
f'"{slow["label"]}" is where packets wait longest right now'
f" (average {pm.fmt_days(slow['avg_s'])}). Start there."
if slow
else "Nothing is waiting in any step."
)
r.text(says, M + 12, y + 30, size=9, color=C["body"], max_width=CW - 24)
def build(
conn: Any,
settings: Any,
*,
scope_params: Mapping[str, Any],
who: WhoRef | None,
now: datetime | None = None,
report: pm.Report | None = None,
) -> Built:
"""The report as PDF bytes, its page count and the stamp. `scope_params` are the find bar values plus
project_id, unit_id and period; a `report` already read for the same scope is used as it is."""
now = now or datetime.now(UTC)
rep = report
if rep is None:
rep = pm.load(
conn,
None,
scope_params,
now=now,
tz=str(getattr(settings, "site_tz", None) or "America/Denver"),
period=scope_params.get("period"),
)
pipe = pm.pipeline(rep)
kpis = pm.kpis(rep)
lines = pm.summary(rep, kpis, pipe)
r = Pages()
_cover(r, rep, kpis, who)
_summary(r, lines, pipe)
r.section(
"PART 02 · PROJECTS",
"Projects and units",
"Every unit's packets by step, how much is done, and whether it's on track.",
[pm.projects_table(rep, expand_all=True)],
)
r.section(
"PART 03 · FLOW",
"Bottlenecks and aging",
"How long packets sit in each step, oldest first.",
pm.aging_tables(rep, pipe),
)
r.section(
"PART 04 · LABOR",
"Hours vs target",
"Station clock hours and VISUAL ticket hours against work-order target hours.",
pm.hours_tables(rep),
)
r.section(
"PART 05 · ISSUES",
"Quality and issues",
"Q.C. rejects, shorts, change orders, holds and send-backs.",
pm.quality_tables(rep),
)
raw = r.finish()
pages = r.page
text = stamps.controlled_stamp(who, now, mode="report")
line, sig = stamps.signed_stamp(settings, text, packet_id=None, kind="report", seal_hash_hex=None, who=who, now=now)
out = stamps.apply_stamp(raw, line)
out = stamps.set_metadata(
out, "Production status report", rep.scope_label(), "Packet Station report", "Packet Station", now=now
)
return Built(pdf=out, pages=pages, stamp=line, stamp_sig=sig or None)
+4 -1
View File
@@ -35,7 +35,10 @@ Draw = Callable[[Canvas, int, float, float], None]
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
says printed by (and the seal or not yet filed), "traveler" carries no seal part, "report" (the PM production
status report) names Packet Station and carries no seal part."""
if mode == "report":
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
+26
View File
@@ -5,6 +5,7 @@ The colours, the page frame and the address lines are the old databook.js values
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
@@ -148,6 +149,31 @@ def fmt_clock(ms: Any) -> str:
return f"{t // 3600}:{t % 3600 // 60:02d}"
def _hms(ms: Any) -> tuple[int, int, int]:
try:
t = max(0, int(ms or 0) // 1000)
except (TypeError, ValueError):
t = 0
return t // 3600, t % 3600 // 60, t % 60
def fmt_clock_hms(ms: Any) -> str:
"""Elapsed milliseconds as the old fmtClock(): HH:MM:SS, zero padded (the weld record's time recorded)."""
h, m, s = _hms(ms)
return f"{h:02d}:{m:02d}:{s:02d}"
def fmt_clock_hhmm(ms: Any) -> str:
"""Elapsed milliseconds as HH:MM, zero padded (the cover's weld time, the old fmtClock().slice(0,5))."""
h, m, _s = _hms(ms)
return f"{h:02d}:{m:02d}"
def round_half_up(x: float) -> int:
"""JavaScript's Math.round for positive numbers: 8.5 gives 9 (Python's round gives 8)."""
return math.floor(x + 0.5)
# ---------------------------------------------------------------- dates