T8.4 - CR-012: the delivery location is the shared vocabulary plus fifty feet

Staging is not the pain; the last fifty feet are - the correct floor lay-down,
shark cage or conduit tree instead of material picked at will by whoever is
closest. The Kitting & MIMO section gains:

- Delivery Building / Floor / Sector: the SAME dependent pickers CR-004 built,
  through the same fillLocSelect (which learned an optional field-map instead
  of being copied), reading the same project location lists, storing PATHS.
  A parallel free-text location vocabulary is exactly what CR-004 removed;
  none was added.
- A free-text detail field for the specifics ("Shark cage 7, conduit tree C"),
  persisted as delivDetail.
- deliveryLoc, the composed display string (labels off the shared lists, then
  the detail after a dash) - which is what the CR-011 email already reads
  (kitting_body preferred deliveryLoc from day one, with mimoLoc as the
  pre-CR-012 fallback) and what the package printout now carries as its own
  Delivery Location row.

Verification (each probe run alone): kitting_check.py extended to 26/26 (the
delivery selects are asserted to offer the SAME option list as the CR-004
trio, values persist as paths, the printout carries the composed value);
kitting_notify_check 17/17 now asserting the mail carries CR-012's composed
value, not the fallback. Regression: locations_check 58/58.

Items: CR-012

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:11:05 -07:00
parent 829fa22236
commit b9d5f8ef92
5 changed files with 99 additions and 8 deletions

View File

@@ -290,7 +290,7 @@ python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields
Wave 8 adds these: Wave 8 adds these:
```bash ```bash
python tests/kitting_check.py # CR-009/CR-010 - statuses, owner, filter 21 checks 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/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
``` ```

View File

@@ -1605,7 +1605,7 @@ function collectPackage(){
gateOverride:pkgGateOverride || undefined, gateOverride:pkgGateOverride || undefined,
assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), assets:pkgAssets.filter(a=>a.tag||a.link||a.desc),
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc), materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitOwnerId:pkgKitOwnerId||'', kitDate:gv('wp_kit_date'), kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitOwnerId:pkgKitOwnerId||'', delivBuilding:gv('wp_deliv_building'), delivFloor:gv('wp_deliv_floor'), delivSector:gv('wp_deliv_sector'), delivDetail:gv('wp_deliv_detail'), deliveryLoc:deliveryLocOf({delivBuilding:gv('wp_deliv_building'), delivFloor:gv('wp_deliv_floor'), delivSector:gv('wp_deliv_sector'), delivDetail:gv('wp_deliv_detail')}), kitDate:gv('wp_kit_date'),
mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'), mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'),
constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})), constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})),
qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'), qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'),
@@ -1759,6 +1759,7 @@ function renderPackage(pkg){
<tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr> <tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr>
<tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr> <tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr>
<tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr> <tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr>
<tr><th>Delivery Location</th><td>${cell(pkg.deliveryLoc || deliveryLocOf(pkg))}</td></tr>
</tbody></table>`); </tbody></table>`);
let ct=`<table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`; let ct=`<table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
@@ -2193,7 +2194,7 @@ function locLabelFull(path){
} }
async function loadLocations(){ async function loadLocations(){
if(!activeProjectId){ wpLocations = []; wpLocationsLoaded = true; buildLocationPickers(); return; } if(!activeProjectId){ wpLocations = []; wpLocationsLoaded = true; buildLocationPickers(); buildDeliveryPickers(); return; }
try { try {
const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId) const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId)
+ '/locations?include_inactive=true', + '/locations?include_inactive=true',
@@ -2209,6 +2210,7 @@ async function loadLocations(){
} }
wpLocationsLoaded = true; wpLocationsLoaded = true;
buildLocationPickers(); buildLocationPickers();
buildDeliveryPickers();
} }
/* Fill one level's <select>, filtered to the children of `parent`. /* Fill one level's <select>, filtered to the children of `parent`.
@@ -2216,8 +2218,8 @@ async function loadLocations(){
`keep` is the value already on the package. It is offered even when it is `keep` is the value already on the package. It is offered even when it is
inactive — otherwise opening an old package would silently blank its location inactive — otherwise opening an old package would silently blank its location
and the next save would write that blank back. */ and the next save would write that blank back. */
function fillLocSelect(level, parent, keep){ function fillLocSelect(level, parent, keep, fieldMap){
const sel = document.getElementById(LOC_FIELD[level]); const sel = document.getElementById((fieldMap || LOC_FIELD)[level]);
if(!sel) return; if(!sel) return;
const wanted = LOC_LEVELS.indexOf(level); const wanted = LOC_LEVELS.indexOf(level);
const options = wpLocations.filter(n => const options = wpLocations.filter(n =>
@@ -2240,6 +2242,36 @@ function fillLocSelect(level, parent, keep){
if(keep) sel.value = keep; if(keep) sel.value = keep;
} }
// CR-012: the delivery trio reads the SAME lists through the same builder -
// a parallel copy of the location vocabulary is exactly what CR-004 removed.
const DELIV_FIELD = {building: 'wp_deliv_building', floor: 'wp_deliv_floor', sector: 'wp_deliv_sector'};
function buildDeliveryPickers(keepValues){
const cur = keepValues || {
building: (document.getElementById('wp_deliv_building') || {}).value || '',
floor: (document.getElementById('wp_deliv_floor') || {}).value || '',
sector: (document.getElementById('wp_deliv_sector') || {}).value || '',
};
fillLocSelect('building', '', cur.building, DELIV_FIELD);
fillLocSelect('floor', cur.building, cur.building ? cur.floor : '', DELIV_FIELD);
fillLocSelect('sector', cur.floor, cur.floor ? cur.sector : '', DELIV_FIELD);
}
function onDeliveryLocChange(level){
const b = (document.getElementById('wp_deliv_building') || {}).value || '';
const f = (document.getElementById('wp_deliv_floor') || {}).value || '';
const s = (document.getElementById('wp_deliv_sector') || {}).value || '';
if(level === 'building') buildDeliveryPickers({building: b, floor: '', sector: ''});
else if(level === 'floor') buildDeliveryPickers({building: b, floor: f, sector: ''});
else buildDeliveryPickers({building: b, floor: f, sector: s});
}
// The display string the kitting email (CR-011) and the printout carry:
// labels off the shared lists, then the free-text detail after a dash.
function deliveryLocOf(p){
const parts = ['delivBuilding','delivFloor','delivSector']
.map(k => p[k]).filter(Boolean).map(locLabelFull);
const detail = (p.delivDetail || '').trim();
return parts.join(' / ') + (detail ? (parts.length ? ' — ' : '') + detail : '');
}
function buildLocationPickers(keepValues){ function buildLocationPickers(keepValues){
const cur = keepValues || { const cur = keepValues || {
building: (document.getElementById('wp_building') || {}).value || '', building: (document.getElementById('wp_building') || {}).value || '',
@@ -2738,6 +2770,8 @@ function loadPackageIntoForm(p){
pkgKitOwnerId=p.kitOwnerId||''; pkgKitOwnerId=p.kitOwnerId||'';
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc); set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
buildKitOwnerPicker(); buildKitOwnerPicker();
set('wp_deliv_detail', p.delivDetail);
buildDeliveryPickers({building:p.delivBuilding||'', floor:p.delivFloor||'', sector:p.delivSector||''});
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons); set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea); set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea);
set('wp_clash',p.clash); set('wp_scan_link',p.scanLink); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
@@ -2826,6 +2860,8 @@ function newPackage(){
document.getElementById('wp_type').value=''; buildKitStatusOptions(''); pkgKitOwnerId=''; buildKitOwnerPicker(); document.getElementById('wp_cost').value=''; document.getElementById('wp_type').value=''; buildKitStatusOptions(''); pkgKitOwnerId=''; buildKitOwnerPicker(); document.getElementById('wp_cost').value='';
const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003 const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003
buildLocationPickers({building:'', floor:'', sector:''}); // CR-004 buildLocationPickers({building:'', floor:'', sector:''}); // CR-004
buildDeliveryPickers({building:'', floor:'', sector:''}); // CR-012
{ const dd=document.getElementById('wp_deliv_detail'); if(dd) dd.value=''; }
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); ['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
onClashChange(); onClashChange();
pkgKind='iwp'; applyKind(); pkgKind='iwp'; applyKind();

View File

@@ -393,6 +393,13 @@
<div class="field"><label>Kitting need date</label><input type="date" id="wp_kit_date"></div> <div class="field"><label>Kitting need date</label><input type="date" id="wp_kit_date"></div>
<div class="field"><label>MIMO sch. time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date &amp; time</div></div> <div class="field"><label>MIMO sch. time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date &amp; time</div></div>
<div class="field"><label>MIMO location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div> <div class="field"><label>MIMO location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
<!-- CR-012 / T8.4: delivery uses the SHARED Building/Floor/Sector lists
(CR-004), never a parallel free-text copy; the detail field carries
the last fifty feet - lay-down area, shark cage, conduit tree. -->
<div class="field"><label>Delivery building</label><select id="wp_deliv_building" onchange="onDeliveryLocChange('building')"><option value="">Building…</option></select></div>
<div class="field"><label>Delivery floor</label><select id="wp_deliv_floor" onchange="onDeliveryLocChange('floor')"><option value="">Floor…</option></select></div>
<div class="field"><label>Delivery sector</label><select id="wp_deliv_sector" onchange="onDeliveryLocChange('sector')"><option value="">Sector…</option></select></div>
<div class="field"><label>Delivery detail</label><input type="text" id="wp_deliv_detail" placeholder="lay-down area, shark cage, conduit tree…"></div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Kitting: statuses, the owner, the filter — CR-009 (T8.1) + CR-010 (T8.2). """Kitting: statuses, owner, filter, delivery — CR-009/CR-010/CR-012 (T8.1/2/4).
The statuses become an explicit five (Not Started / Picking / Staged / The statuses become an explicit five (Not Started / Picking / Staged /
In Transit / Delivered) - fulfillment states, not free text. A value stored In Transit / Delivered) - fulfillment states, not free text. A value stored
@@ -25,6 +25,7 @@ import cdp # noqa: E40
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402 from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402 from stepper_check import dismiss_dialogs # noqa: E402
from qa_gate_check import api # noqa: E402
KIT = ["Not Started", "Picking", "Staged", "In Transit", "Delivered"] KIT = ["Not Started", "Picking", "Staged", "In Transit", "Delivered"]
@@ -186,6 +187,50 @@ def main():
page.eval("!!document.querySelector('[aria-label=%s]')" page.eval("!!document.querySelector('[aria-label=%s]')"
% json.dumps("Filter by warehouse owner"))) % json.dumps("Filter by warehouse owner")))
# ── 5. CR-012: the delivery location (T8.4) ──────────────────────────
print(chr(10) + "5. CR-012: delivery on the shared lists")
code, b1 = api(base, "/api/projects/projA/locations", tok["root"], "POST",
{"level": "building", "name": "B-100"})
code, f1 = api(base, "/api/projects/projA/locations", tok["root"], "POST",
{"level": "floor", "parent_id": b1["id"], "name": "Level 3"})
code, s1 = api(base, "/api/projects/projA/locations", tok["root"], "POST",
{"level": "sector", "parent_id": f1["id"], "name": "Sector East"})
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
wait_creator(page)
settle(1.8)
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
opts = json.loads(page.eval(
"JSON.stringify([...document.getElementById('wp_deliv_building').options].map(o=>o.value))"))
wp_opts = json.loads(page.eval(
"JSON.stringify([...document.getElementById('wp_building').options].map(o=>o.value))"))
chk("the delivery building select is fed by the SAME shared list as CR-004's",
opts == wp_opts and len(opts) > 1, ascii_((opts, wp_opts)))
page.eval("document.getElementById('wp_deliv_building').value=%s; onDeliveryLocChange('building')"
% json.dumps(b1["path"]))
settle(0.3)
page.eval("document.getElementById('wp_deliv_floor').value=%s; onDeliveryLocChange('floor')"
% json.dumps(f1["path"]))
settle(0.3)
page.eval("document.getElementById('wp_deliv_sector').value=%s; onDeliveryLocChange('sector')"
% json.dumps(s1["path"]))
page.eval("document.getElementById('wp_deliv_detail').value='Shark cage 7, conduit tree C'")
got = json.loads(page.eval("""JSON.stringify((() => { const p = collectPackage();
return {b: p.delivBuilding, f: p.delivFloor, s: p.delivSector,
d: p.delivDetail, disp: p.deliveryLoc}; })())"""))
chk("the picked values persist as PATHS off the shared lists",
got["b"] == b1["path"] and got["f"] == f1["path"] and got["s"] == s1["path"],
ascii_(got))
chk("...the detail field persists", got["d"] == "Shark cage 7, conduit tree C")
chk("...and the composed display carries labels AND the detail",
"B-100" in got["disp"] and "Shark cage 7" in got["disp"], ascii_(got["disp"]))
page.eval("document.getElementById('wp_subject').value='deliv print'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
doc = page.eval("(() => { renderPackage(collectPackage()); "
"return document.getElementById('pkg-doc').innerHTML; })()")
chk("the delivery location prints on the package output",
"Delivery Location" in doc and "Shark cage 7" in doc, ascii_(doc, 120))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e] 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, chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2])) ascii_(js_errors[:2]))

View File

@@ -38,7 +38,9 @@ def ascii_(v, n=300):
def save_wp(base, tok, kit_status, extra=None): def save_wp(base, tok, kit_status, extra=None):
data = {"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}], data = {"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}],
"kitStatus": kit_status, "kitOwner": "Sue", "kitOwnerId": "user_sue", "kitStatus": kit_status, "kitOwner": "Sue", "kitOwnerId": "user_sue",
"distributionIds": ["user_pat"], "mimoLoc": "Staging 04, dock B"} "distributionIds": ["user_pat"], "mimoLoc": "Staging 04, dock B",
# CR-012 (T8.4): the composed delivery location wins over mimoLoc
"deliveryLoc": "B-100 / Level 3 / Sector East — Shark cage 7"}
data.update(extra or {}) data.update(extra or {})
return api(base, "/api/wps", tok, "POST", { return api(base, "/api/wps", tok, "POST", {
"id": "wpKN1", "project_id": "projA", "number": "KN-1", "id": "wpKN1", "project_id": "projA", "number": "KN-1",
@@ -108,7 +110,8 @@ def main():
chk("the mail says old status, new status and who", chk("the mail says old status, new status and who",
"In Transit" in body and "Delivered" in body and "Root" in body, "In Transit" in body and "Delivered" in body and "Root" in body,
ascii_(body, 260)) ascii_(body, 260))
chk("...and the delivery location", "Staging 04, dock B" in body) chk("...and the delivery location - CR-012's composed value, not the "
"mimoLoc fallback", "Shark cage 7" in body and "Staging 04" not in body)
chk("...and a deep link to THAT package, not the app root", chk("...and a deep link to THAT package, not the app root",
"/wp-creation-index.html?project=projA&wp=wpKN1" in body) "/wp-creation-index.html?project=projA&wp=wpKN1" in body)
chk("...in the house convention (greeting + automated-message footer)", chk("...in the house convention (greeting + automated-message footer)",