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:
@@ -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
|
||||
|
||||
@@ -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,'"')}">${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,'"')}" aria-label="Quantity, line ${i+1}" oninput="_mreqDraft[${i}].qty=this.value"></td>
|
||||
<td><input type="text" value="${(it.unit||'').replace(/"/g,'"')}" 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,'"')}" 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();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
201
tests/mreq_check.py
Normal 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())
|
||||
Reference in New Issue
Block a user