Add discipline strategy, WP sizing, Split-by-Discipline & dashboard
SOP config (Governance step) now sets project discipline policy:
- disciplines list, discipline strategy (single/multi/planner-choice),
letter instance-suffix style, and a max-hours split threshold.
WP Creator becomes discipline-aware:
- discipline picker; selecting 2+ turns the flat scope into per-discipline
scope sections, each with its own status (rolls up to least-advanced).
- "Split by Discipline" turns a multi-discipline WP into WP01A/B/C instances
linked to a kept master (instanceOf/parentNumber/split/children).
- est-hours warning against the SOP split threshold.
New WP Dashboard (header button, home card, ?view=dashboard deep-link):
metrics, status/discipline breakdowns, a gating panel, and a filterable
board with view/edit/issue. Reads localStorage via an API-ready WPData
adapter; masters excluded from counts.
Backend: POST /api/wps/{id}/issue (enforces the constraint gate),
POST /api/wps/{id}/status, GET /api/wps/metrics; work_packages gains
parent_id + issued_at.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ class SopIn(BaseModel):
|
||||
class WpIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
sop_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
number: str = ""
|
||||
subject: str = ""
|
||||
type: str = ""
|
||||
@@ -61,6 +62,10 @@ class WpIn(BaseModel):
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StatusIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -141,6 +146,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
wp.sop_id = body.sop_id
|
||||
wp.parent_id = body.parent_id
|
||||
wp.number = body.number
|
||||
wp.subject = body.subject
|
||||
wp.type = body.type
|
||||
@@ -153,14 +159,63 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.get("/api/wps")
|
||||
def list_wps(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def list_wps(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.WorkPackage)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
if parent_id:
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if status:
|
||||
stmt = stmt.where(models.WorkPackage.status == status)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
return [w.summary() for w in rows]
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
stmt = select(models.WorkPackage)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
rows = db.scalars(stmt).all()
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
by_discipline: dict[str, int] = {}
|
||||
total = ready = on_hold = est_hours = actual_hours = 0
|
||||
for w in rows:
|
||||
data = w.data or {}
|
||||
if data.get("split"):
|
||||
continue
|
||||
total += 1
|
||||
by_status[w.status] = by_status.get(w.status, 0) + 1
|
||||
if w.status == "Issue":
|
||||
on_hold += 1
|
||||
constraints = data.get("constraints") or []
|
||||
open_count = sum(1 for c in constraints if c.get("status") == "open")
|
||||
if open_count == 0 and w.status not in ("Closed", "Issue"):
|
||||
ready += 1
|
||||
try:
|
||||
est_hours += float(data.get("hours") or 0)
|
||||
actual_hours += float(data.get("actualHrs") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for d in (data.get("disciplines") or ["(none)"]):
|
||||
by_discipline[d] = by_discipline.get(d, 0) + 1
|
||||
|
||||
return {
|
||||
"total": total, "release_ready": ready, "on_hold": on_hold,
|
||||
"est_hours": round(est_hours), "actual_hours": round(actual_hours),
|
||||
"by_status": by_status, "by_discipline": by_discipline,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/wps/{wp_id}")
|
||||
def get_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
@@ -179,6 +234,37 @@ def delete_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
return {"deleted": wp_id}
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/issue")
|
||||
def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
"""Release a Work Package to the field. Refuses if any constraint is still
|
||||
open (the AWP release gate)."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
constraints = (wp.data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||
if open_names:
|
||||
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
|
||||
wp.status = "Issued"
|
||||
wp.issued_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/status")
|
||||
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
# ── Comments / feedback ──────────────────────────────────────────────────────
|
||||
def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
extra = body.model_extra or {}
|
||||
|
||||
@@ -51,10 +51,13 @@ class WorkPackage(Base):
|
||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
# parent_id links a discipline instance (WP01A) back to its master (WP01).
|
||||
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
number: Mapped[str] = mapped_column(String(120), default="")
|
||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||
type: Mapped[str] = mapped_column(String(120), default="")
|
||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -62,8 +65,9 @@ class WorkPackage(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "sop_id": self.sop_id, "number": self.number,
|
||||
"subject": self.subject, "type": self.type, "status": self.status,
|
||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
||||
"number": self.number, "subject": self.subject, "type": self.type,
|
||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user