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

@@ -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

View File

@@ -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 =>
`<option value="${(m.description||'').replace(/"/g,'&quot;')}">${esc(m.unit||'')}</option>`).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 = `<td><input type="text" inputmode="decimal" value="${(it.qty||'').replace(/"/g,'&quot;')}" aria-label="Quantity, line ${i+1}" oninput="_mreqDraft[${i}].qty=this.value"></td>
<td><input type="text" value="${(it.unit||'').replace(/"/g,'&quot;')}" aria-label="Unit, line ${i+1}" oninput="_mreqDraft[${i}].unit=this.value"></td>
<td><input type="text" list="mat-datalist" value="${(it.desc||'').replace(/"/g,'&quot;')}" aria-label="Description, line ${i+1}"
oninput="_mreqDraft[${i}].desc=this.value" onchange="mreqMaybeUnit(${i}, this.value)"></td>
<td><button type="button" class="btn btn-ghost" aria-label="Remove line ${i+1}" onclick="_mreqDraft.splice(${i},1);mreqRenderDraft()">✕</button></td>`;
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 => `<div class="mreq-item">
<div class="mr-head">
<span class="mr-status">${esc(r.status||'Requested')}</span>
<span>${esc(r.requestor||'')}</span>
${r.neededBy?`<span>needed ${esc(r.neededBy)}</span>`:''}
${r.deliveryLoc?`<span>→ ${esc(r.deliveryLoc)}</span>`:''}
<select aria-label="Status of this request" onchange="mreqSetStatus('${r.id}', this.value)">
${MREQ_STATUSES.map(st=>`<option${st===(r.status||'Requested')?' selected':''}>${esc(st)}</option>`).join('')}
</select>
</div>
<div class="mr-lines">${(r.items||[]).map(it=>esc([it.qty, it.unit, it.desc].filter(Boolean).join(' '))).join(' · ')}</div>
</div>`).join('');
}
function buildKitOwnerPicker(){
const sel=document.getElementById('wp_kit_owner_sel'); if(!sel) return;
const curId=pkgKitOwnerId||'';
@@ -1613,6 +1727,7 @@ function collectPackage(){
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
holds:pkgHolds.map(h=>({...h})),
qaRejections:pkgQaRejections.map(x=>({...x})),
materialRequests:pkgMatRequests.map(x=>({...x})),
files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
@@ -2808,6 +2923,7 @@ function loadPackageIntoForm(p){
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
pkgHolds=(p.holds||[]).map(h=>({...h}));
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
pkgMatRequests=(p.materialRequests||[]).map(x=>({...x})); mreqRenderList();
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
}
@@ -2876,6 +2992,7 @@ function newPackage(){
pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={};
pkgMatRequests=[]; _mreqDraft=[]; mreqRenderDraft(); mreqRenderList();
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
@@ -3397,8 +3514,43 @@ function renderDashboard(){
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next </button></span></div>`;
}
h+=`</div>`;
h+=renderMreqPanel(all);
document.getElementById('dash-body').innerHTML=h;
}
// CR-013: every request across the project, on the board, filterable by
// status and by delivery location - the funnel-through-one-person replaced by
// a queue anyone can read.
let mreqDashFilter = {status:'', loc:''};
function renderMreqPanel(all){
const reqs = [];
all.forEach(p => (p.materialRequests||[]).forEach(r => reqs.push({p, r})));
if(!reqs.length) return '';
const locs = [...new Set(reqs.map(x => x.r.deliveryLoc || '').filter(Boolean))].sort();
const rows = reqs.filter(x =>
(!mreqDashFilter.status || (x.r.status||'Requested') === mreqDashFilter.status)
&& (!mreqDashFilter.loc || (x.r.deliveryLoc||'') === mreqDashFilter.loc));
let h = `<div class="dash-panel"><div class="dash-panel-title">Material requests</div>
<div class="dash-filters" style="margin:6px 0">
<select aria-label="Filter requests by status" onchange="mreqDashFilter.status=this.value;renderDashboard()">
<option value="">All statuses</option>
${MREQ_STATUSES.map(st=>`<option${mreqDashFilter.status===st?' selected':''}>${esc(st)}</option>`).join('')}
</select>
<select aria-label="Filter requests by delivery location" onchange="mreqDashFilter.loc=this.value;renderDashboard()">
<option value="">All delivery locations</option>
${locs.map(l=>`<option${mreqDashFilter.loc===l?' selected':''}>${esc(l)}</option>`).join('')}
</select>
</div>`;
h += rows.length ? rows.map(x => `<div class="mreq-item">
<div class="mr-head"><span class="mr-status">${esc(x.r.status||'Requested')}</span>
<b>${esc(x.p.number||'')}</b> <span>${esc(x.r.requestor||'')}</span>
${x.r.neededBy?`<span>needed ${esc(x.r.neededBy)}</span>`:''}
${x.r.deliveryLoc?`<span>→ ${esc(x.r.deliveryLoc)}</span>`:''}</div>
<div class="mr-lines">${(x.r.items||[]).map(it=>esc([it.qty,it.unit,it.desc].filter(Boolean).join(' '))).join(' · ')}</div>
</div>`).join('')
: '<div class="mr-lines">No requests match the filters.</div>';
return h + '</div>';
}
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();

View File

@@ -348,6 +348,28 @@
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download template</button>
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
</div>
<!-- MATERIAL REQUESTS (CR-013 / D6, T8.5). The lightweight scope, exactly:
line items, needed-by, requestor, delivery (T8.4's fields on this
package), status. Items pick from the project material list through
the datalist when one exists and stay free text when it does not.
No catalog, no inventory, no warehouse integration. -->
<div style="margin-top:1.5rem; border-top:1px solid var(--border); padding-top:1rem;">
<div class="sub-heading">Material Requests<span class="help-tip" data-tip="Field requests for material against this package. Submitting notifies the warehouse owner named above (CR-010). Delivery uses this package's delivery location.">i</span></div>
<div id="mreq-list" class="mreq-list"></div>
<div class="mreq-new" id="mreq-new">
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:110px">Unit</th><th>Description</th><th style="width:44px"></th></tr></thead><tbody id="mreq-items"></tbody></table></div>
<datalist id="mat-datalist"></datalist>
<div class="mreq-row">
<button type="button" class="add-btn" onclick="mreqAddItem()">+ Add line</button>
<label for="mreq-needed">Needed by</label>
<input type="date" id="mreq-needed">
<button type="button" class="btn btn-generate" onclick="mreqSubmit()">Submit request</button>
</div>
<div class="field-hint" id="mreq-hint"></div>
<div class="field-error" id="mreq-err" role="alert"></div>
</div>
</div>
</div>
<!-- DRAWINGS / ATTACHMENTS -->

View File

@@ -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); }

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:

201
tests/mreq_check.py Normal file
View File

@@ -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())