diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 5b5de6c..a21fc38 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -293,6 +293,7 @@ Wave 8 adds these:
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
+python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index dd13d57..84ba7bb 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -68,6 +68,120 @@ const KIT_STATUSES = ['Not Started', 'Picking', 'Staged', 'In Transit', 'Deliver
// the project) is KEPT as a selected "(no account)" option - existing packages
// must never break because the roster changed.
let pkgKitOwnerId = '';
+
+// ── CR-013 / T8.5: material requests - the lightweight scope, exactly ─────────
+// Line items, needed-by, requestor, delivery (T8.4's fields), status. Items
+// pick from the project material list (D6) through a datalist - which is also
+// what keeps free text working when no list has been loaded, the state every
+// project is in today. The request rides on the package (data.materialRequests),
+// so it is server-persisted through the same upsert as everything else, and the
+// server notifies the warehouse owner (CR-010) when one is added.
+const MREQ_STATUSES = ['Requested', 'Filled', 'Declined'];
+let pkgMatRequests = [];
+let _mreqDraft = [];
+let _projMaterials = null; // the D6 list; null until fetched, [] when none
+
+async function mreqLoadMaterials(){
+ if(!activeProjectId){ _projMaterials = []; mreqFillDatalist(); return; }
+ try{
+ const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId) + '/materials');
+ _projMaterials = r.ok ? ((await r.json()).items || []) : [];
+ }catch(e){ _projMaterials = []; }
+ mreqFillDatalist();
+}
+function mreqFillDatalist(){
+ const dl = document.getElementById('mat-datalist');
+ if(!dl) return;
+ dl.innerHTML = (_projMaterials || []).map(m =>
+ ``).join('');
+ const hint = document.getElementById('mreq-hint');
+ if(hint) hint.textContent = (_projMaterials && _projMaterials.length)
+ ? 'Descriptions offer the project material list (D6); anything else can be typed.'
+ : 'No project material list loaded - describe what you need in free text.';
+}
+function mreqAddItem(){
+ _mreqDraft.push({qty:'', unit:'', desc:''});
+ mreqRenderDraft();
+}
+function mreqRenderDraft(){
+ const tb = document.getElementById('mreq-items');
+ if(!tb) return;
+ tb.innerHTML = '';
+ _mreqDraft.forEach((it, i) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+
+
+
`;
+ tb.appendChild(tr);
+ });
+}
+// Picking a listed material fills its unit - a convenience, never a lock.
+function mreqMaybeUnit(i, desc){
+ const hit = (_projMaterials || []).find(m => m.description === desc);
+ if(hit && _mreqDraft[i] && !_mreqDraft[i].unit){ _mreqDraft[i].unit = hit.unit || ''; mreqRenderDraft(); }
+}
+function mreqSubmit(){
+ const err = document.getElementById('mreq-err');
+ const items = _mreqDraft.map(it => ({qty:(it.qty||'').trim(), unit:(it.unit||'').trim().toUpperCase(), desc:(it.desc||'').trim()}))
+ .filter(it => it.desc || it.qty);
+ if(!items.length){
+ if(err) err.textContent = 'Add at least one line - what material, and how much.';
+ return;
+ }
+ const bad = items.find(it => !it.desc);
+ if(bad){
+ if(err) err.textContent = 'Every line needs a description.';
+ return;
+ }
+ if(err) err.textContent = '';
+ const needed = (document.getElementById('mreq-needed')||{value:''}).value;
+ const deliv = {
+ delivBuilding: gv('wp_deliv_building'), delivFloor: gv('wp_deliv_floor'),
+ delivSector: gv('wp_deliv_sector'), delivDetail: gv('wp_deliv_detail'),
+ };
+ pkgMatRequests.push({
+ id: 'mreq_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6),
+ ts: new Date().toISOString(),
+ requestor: (window.WP_USER && (WP_USER.full_name || WP_USER.username)) || '',
+ neededBy: needed, status: 'Requested', items,
+ deliveryLoc: deliveryLocOf(deliv),
+ });
+ _mreqDraft = [];
+ const nd = document.getElementById('mreq-needed'); if(nd) nd.value = '';
+ mreqRenderDraft(); mreqRenderList();
+ // The request is data on the package: saving is what persists it and what
+ // makes the server notify the warehouse owner. Do it now rather than leaving
+ // a submitted-looking request sitting unsaved in a form.
+ if(editingId){ savePackage(false); }
+ else toast('Request added - save the package to send it to the warehouse owner.', 'alert');
+ track('material_requested', {lines: items.length});
+}
+function mreqSetStatus(id, status){
+ const r = pkgMatRequests.find(x => x.id === id);
+ if(!r || MREQ_STATUSES.indexOf(status) < 0) return;
+ r.status = status;
+ mreqRenderList();
+ if(editingId) savePackage(false);
+}
+function mreqRenderList(){
+ const box = document.getElementById('mreq-list');
+ if(!box) return;
+ if(!pkgMatRequests.length){ box.innerHTML = ''; return; }
+ box.innerHTML = pkgMatRequests.map(r => `
';
+}
+
async function dashIssue(id){
const p=WPData.get(id); if(!p) return;
if(wpOpenConstraints(p).length>0){ toast('Cannot issue — open constraints remain.', 'alert'); return; }
@@ -3659,6 +3811,7 @@ function bootData(){
loadMembers();
loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
+ mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
initWpNavDrawer();
renderSavedList();
positionSectionNav();
diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html
index be58cad..cbfb476 100644
--- a/html/wp-creation-index.html
+++ b/html/wp-creation-index.html
@@ -348,6 +348,28 @@
+
+
+
+
Material Requestsi
+
+
+
Qty
Unit
Description
+
+
+
+
+
+
+
+
+
+
+
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index b8be218..0150ce4 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -595,6 +595,15 @@
.wp-file-item input { flex:1 1 200px; font-size:12px; }
.wp-file-x { margin-left:auto; }
+ /* CR-013: material requests. Rows wrap at 390px - requests originate in
+ the field. */
+ .mreq-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:8px; }
+ .mreq-list { display:flex; flex-direction:column; gap:8px; margin:8px 0; }
+ .mreq-item { border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; font-size:12px; }
+ .mreq-item .mr-head { display:flex; gap:10px; flex-wrap:wrap; align-items:center; font-weight:600; }
+ .mreq-item .mr-status { padding:1px 8px; border-radius:9px; background:var(--accent-dim); color:var(--accent); font-size:11px; font-weight:700; }
+ .mreq-item .mr-lines { color:var(--text-muted); margin-top:3px; }
+
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }
diff --git a/server/app.py b/server/app.py
index 54ac505..1b02ed5 100644
--- a/server/app.py
+++ b/server/app.py
@@ -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:
diff --git a/tests/mreq_check.py b/tests/mreq_check.py
new file mode 100644
index 0000000..f1db5b1
--- /dev/null
+++ b/tests/mreq_check.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""Is the material request the lightweight scope, wired to the list? — CR-013, T8.5.
+
+Requests ran through per-floor Teams chats and a spreadsheet, all funneling to
+one person. The structure that replaces it: line items (qty, unit, description),
+needed-by, requestor, delivery (T8.4's fields), status - riding on the package
+record, visible on the dashboard, filterable by status and delivery location,
+notifying the warehouse owner (CR-010) through the T7.6 gate. Items pick from
+the D6 list through a datalist, which is also exactly what keeps free text
+working when no list exists.
+
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+from qa_gate_check import SmtpSink, api, wait_for # noqa: E402
+
+
+def ascii_(v, n=280):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.5):
+ time.sleep(seconds)
+
+
+def wait_creator(page, tries=40):
+ for _ in range(tries):
+ if page.eval("!!window.wpCreatorReady"):
+ return True
+ time.sleep(0.3)
+ return False
+
+
+def main():
+ exe = cdp.find_browser()
+ if not exe:
+ print("no headless-capable browser found; set WP_BROWSER.")
+ return 2
+
+ tmpdir = tempfile.mkdtemp(prefix="wpsuite-mreq-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ sink = SmtpSink()
+ sink.start()
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+ root = tok["root"]
+ api(base, "/api/projects/projA/materials/import", root, "POST",
+ {"text": "Sample 3/4in EMT,FT,S-EMT\nSample strut,FT", "dry_run": False})
+ api(base, "/api/settings", root, "PUT", {
+ "email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
+ "smtp_use_tls": False, "from_addr": "suite@sink.local", "app_base_url": base})
+
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", root)
+ page.viewport(390, 844, mobile=True) # requests originate in the field
+ page.goto(base + "/wp-creation-index.html?project=projA")
+ dismiss_dialogs(page)
+ chk("the creator boots at 390px", wait_creator(page))
+ settle(1.8)
+ page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
+
+ # ── 1. the request, end to end ────────────────────────────────────────
+ print("\n1. raise a request")
+ page.eval("document.getElementById('wp_subject').value='mreq host'")
+ page.eval("document.getElementById('wp_type').value='Conduit Install'")
+ page.eval("""(() => {
+ const sel=document.getElementById('wp_kit_owner_sel');
+ sel.value='user_sue'; sel.onchange.call(sel);
+ })()""")
+ page.eval("document.getElementById('wp_deliv_detail').value='Shark cage 7'")
+ page.eval("void savePackage(false)")
+ settle(1.2)
+
+ chk("the description datalist offers the D6 list",
+ page.eval("document.querySelectorAll('#mat-datalist option').length") == 2)
+ chk("...and says so", "material list" in page.eval(
+ "(document.getElementById('mreq-hint')||{textContent:''}).textContent"))
+
+ page.eval("mreqAddItem(); mreqAddItem()")
+ page.eval("_mreqDraft[0]={qty:'400',unit:'',desc:'Sample 3/4in EMT'};"
+ "mreqMaybeUnit(0,'Sample 3/4in EMT')")
+ page.eval("_mreqDraft[1]={qty:'2',unit:'EA',desc:'Left-handed flange spreader'};"
+ "mreqRenderDraft()")
+ chk("picking a listed material fills its unit",
+ json.loads(page.eval("JSON.stringify(_mreqDraft[0])"))["unit"] == "FT")
+ page.eval("document.getElementById('mreq-needed').value='2026-09-01'")
+ page.eval("mreqSubmit()")
+ settle(1.2)
+ req = json.loads(page.eval("JSON.stringify(pkgMatRequests[0]||{})"))
+ chk("the request records lines, needed-by, requestor, delivery and status",
+ len(req.get("items", [])) == 2 and req.get("neededBy") == "2026-09-01"
+ and req.get("requestor") and req.get("status") == "Requested"
+ and "Shark cage 7" in (req.get("deliveryLoc") or ""), ascii_(req))
+ chk("...with the free-text line accepted beside the listed one",
+ any("flange spreader" in (it.get("desc") or "") for it in req["items"]))
+
+ wp_id = page.eval("editingId")
+ _, wp = api(base, "/api/wps/" + wp_id, root)
+ chk("...persisted on the server record, not in this browser",
+ len((wp.get("data") or {}).get("materialRequests") or []) == 1)
+ chk("...and the warehouse owner is notified through the T7.6 gate",
+ wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12))
+ body = next((m for m in sink.messages if "Material request" in m["data"]), {"data": "", "to": [""]})
+ chk("...the mail goes to the owner, says the size, the date, the delivery "
+ "and carries the deep link",
+ body["to"] == ["sue@example.test"] and "2 lines" in body["data"]
+ and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"]
+ and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"],
+ ascii_(body["data"], 300))
+ _, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root)
+ chk("...and the audit history has it", bool(ev))
+
+ page.eval("""(() => {
+ const sel=document.querySelector('#mreq-list select');
+ sel.value='Filled'; sel.dispatchEvent(new Event('change'));
+ })()""")
+ settle(1.0)
+ _, wp = api(base, "/api/wps/" + wp_id, root)
+ chk("the status set is explicit and a change persists",
+ (wp.get("data") or {}).get("materialRequests", [{}])[0].get("status") == "Filled")
+
+ chk("390px: the request block does not push the page sideways",
+ page.eval("document.getElementById('mreq-new').scrollWidth <= 392"))
+
+ # ── 2. the dashboard queue ────────────────────────────────────────────
+ print("\n2. the dashboard")
+ page.viewport(1440, 900)
+ page.eval("showDashboard()")
+ settle(1.4)
+ board = lambda: page.eval("(document.getElementById('dash-body')||{textContent:''}).textContent")
+ chk("requests appear on the board", "Material requests" in board()
+ and "flange spreader" in board())
+ page.eval("mreqDashFilter.status='Requested'; renderDashboard()")
+ settle(0.6)
+ chk("filter by status: a Filled request leaves the Requested view",
+ "flange spreader" not in board())
+ page.eval("mreqDashFilter.status='Filled'; renderDashboard()")
+ settle(0.6)
+ chk("...and appears in the Filled one", "flange spreader" in board())
+ page.eval("mreqDashFilter.status=''; mreqDashFilter.loc='nowhere'; renderDashboard()")
+ settle(0.6)
+ chk("filter by delivery location works the same way",
+ "flange spreader" not in board())
+
+ # ── 3. the fences ─────────────────────────────────────────────────────
+ print("\n3. the fences")
+ src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "html", "wp-creation-app.js"), encoding="utf-8").read()
+ block = src[src.find("CR-013 / T8.5"):src.find("function dashIssue")]
+ chk("no catalog, inventory count or warehouse integration in the diff",
+ not re.search(r"on_hand|stock_|inventory_|warehouse_api", block, re.I))
+ chk("the section respects CR-006 - requests live inside the materials card",
+ page.eval("!!document.querySelector('#material-card #mreq-new')"))
+
+ js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
+ chk("no JavaScript errors anywhere in this run", not js_errors,
+ ascii_(js_errors[:2]))
+
+ finally:
+ sink.stop()
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())