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 => `
+
+ ${esc(r.status||'Requested')} + ${esc(r.requestor||'')} + ${r.neededBy?`needed ${esc(r.neededBy)}`:''} + ${r.deliveryLoc?`→ ${esc(r.deliveryLoc)}`:''} + +
+
${(r.items||[]).map(it=>esc([it.qty, it.unit, it.desc].filter(Boolean).join(' '))).join(' · ')}
+
`).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(){ `; } h+=``; + 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 = `
Material requests
+
+ + +
`; + h += rows.length ? rows.map(x => `
+
${esc(x.r.status||'Requested')} + ${esc(x.p.number||'')} ${esc(x.r.requestor||'')} + ${x.r.neededBy?`needed ${esc(x.r.neededBy)}`:''} + ${x.r.deliveryLoc?`→ ${esc(x.r.deliveryLoc)}`:''}
+
${(x.r.items||[]).map(it=>esc([it.qty,it.unit,it.desc].filter(Boolean).join(' '))).join(' · ')}
+
`).join('') + : '
No requests match the filters.
'; + return h + '
'; +} + 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
+
+
+
QtyUnitDescription
+ +
+ + + + +
+
+ +
+
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())