T8.5 - CR-013/D6: the material request is structure, not features

The OneNote comparison from the meeting was "word vomit"; the structure that
replaces it, built at the lightweight scope EXACTLY as approved Aug 14:

- Line items (qty, unit, description) added, edited, removed. Descriptions
  offer the D6 project list through a datalist - which is also precisely what
  keeps free text working when no list is loaded, the state every project is
  in today. Picking a listed material fills its unit; nothing locks.
- Needed-by date, requestor (the signed-in account), delivery location (T8.4's
  fields on this package, composed), and an explicit status set
  (Requested / Filled / Declined). The request rides on the package record
  (data.materialRequests) - server-persisted through the same upsert as
  everything else, never localStorage.
- Submitting notifies the warehouse owner named on the package (CR-010) - the
  routing that replaces the funnel through one person - through the T7.6 gate,
  with the count, the needed-by, the delivery location and the deep link, in
  the house convention. material_requested lands in the audit history.
- The dashboard grows a Material requests queue, filterable by status and by
  delivery location.
- The block lives inside #material-card, so the CR-006 materials toggle
  governs it with no special casing. The whole flow is driven at 390px -
  requests originate in the field.
- NO parts catalog, no inventory count, no warehouse integration - the probe
  greps the block for them.

One infrastructure bug fixed in passing detection (not silently): T8.5's
dashboard-panel insert matched the substring inside "async function
dashIssue", splitting the async keyword from its function - the creator
failed to parse and every boot died. Caught by the probe's first run;
anchored fixes now restore both halves.

Verification (each probe run alone): NEW tests/mreq_check.py 19/19 (request
end-to-end at 390px against the SMTP sink, dashboard filters, fences).
Regressions: frame_check 39/39, sections_check 95/95, kitting_check 26/26.

Items: CR-013, D6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:33:34 -07:00
parent 190144c539
commit 898e5dab94
6 changed files with 429 additions and 0 deletions

View File

@@ -1732,6 +1732,22 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
)
def material_request_body(user: "models.User", wp: "models.WorkPackage",
actor: "models.User", n_lines: int, needed: str,
delivery: str, link: str) -> str:
who = actor.full_name or actor.username
name = user.full_name or user.username
needed_line = f" needed by {needed}" if needed else ""
return (
f"Hi {name},\n\n"
f"{who} raised a material request on {wp.number or 'a work package'}: "
f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
)
def notify_kitting_change(db: Session, wp: "models.WorkPackage", actor: "models.User",
old_status: str, new_status: str) -> list:
"""CR-011: the package's distribution list plus its warehouse owner (CR-010's
@@ -1929,6 +1945,33 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
summary=(wp.number or wp.subject or wp.id),
detail={"from": _kit_old, "to": _kit_new})
notifs.extend(notify_kitting_change(db, wp, user, _kit_old, _kit_new))
# CR-013 / T8.5: a new material request notifies the warehouse owner named
# on the package (CR-010) - the routing that replaces the informal funnel
# through one person. Same gate, same outbox, same link discipline.
if not is_new:
_mr_old = [r for r in ((old_data or {}).get("materialRequests") or []) if isinstance(r, dict)]
_mr_new = [r for r in ((wp.data or {}).get("materialRequests") or []) if isinstance(r, dict)]
if len(_mr_new) > len(_mr_old):
fresh = _mr_new[len(_mr_old):]
for req in fresh:
log_event(db, user, "material_requested", "wp", wp.id,
project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id),
detail={"lines": len(req.get("items") or []),
"neededBy": str(req.get("neededBy") or "")[:40]})
ko = (wp.data or {}).get("kitOwnerId")
owner = db.get(models.User, ko) if isinstance(ko, str) and ko else None
if owner and owner.is_active and owner.id != user.id:
link = wp_link(db, wp)
n_lines = sum(len(r.get("items") or []) for r in fresh)
needed = str(fresh[-1].get("neededBy") or "").strip()
deliv = str(fresh[-1].get("deliveryLoc") or "").strip() or "not set"
notifs.append(notify.enqueue(
db, user=owner, kind="material_requested",
subject=f"Material request: {wp.number or 'work package'}",
body=material_request_body(owner, wp, user, n_lines, needed, deliv, link),
link=link, wp_id=wp.id, project_id=wp.project_id,
))
# Notify a newly-assigned owner (skip self-assignment).
notif = None
if new_assignee and new_assignee != old_assignee and new_assignee != user.id: