From 190144c539062067c7ad905a4c544e4e8b272602 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 12:24:21 -0700 Subject: [PATCH] T8.6 - D6: the material list uploads the way the location list does CR-013 accepted free text because the master workbook never arrived; the Aug 18 call was the CR-005 call again - build the upload path now. THE component, extracted: T5.4's paste-or-file machinery (file read in the browser, ONE parser on the server; dry-run check; a report naming every rejected row with its source line; an editable list that deactivates rather than deletes) moved from the location-specific functions into html/wp-list-import.js. The location list and the new material list are both instances of it - the done-when's "against the same component, not beside it" made literally true. The loc* names survive as thin delegates because row handlers, step entry and the probes call them; locations_check re-pointed its fetch-count assertion to where the fetches now live and still demands every read and write reach the server. The material list itself: description, unit, optional code - one new table (Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005 pattern, deactivate-never-delete, reactivation reuses the same row so nothing referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075). NO inventory, price, stock or warehouse field anywhere - the probe walks the model's columns by regex. The wizard hosts it on step 11 beside the location list, optional by design: a project with no list still raises free-text requests (T8.5 wires that). Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma, so ',FT' - an empty description - was accepted as a material named FT. rstrip only, now; the empty first column is rejected with its line number. Verification (each probe run alone): NEW tests/materials_check.py 17/17. Regression: locations_check 58/58 through the shared component. Items: D6 Co-Authored-By: Claude Fable 5 --- docs/reference/file-map.md | 1 + html/work-package-suite-app.js | 287 +++++++----------- html/work-package-suite.html | 46 +++ html/wp-list-import.js | 210 +++++++++++++ ...1b8c6d4e2f9_material_items_project_list.py | 45 +++ server/app.py | 165 ++++++++++ server/models.py | 26 ++ tests/locations_check.py | 12 +- tests/materials_check.py | 169 +++++++++++ 9 files changed, 791 insertions(+), 170 deletions(-) create mode 100644 html/wp-list-import.js create mode 100644 server/alembic/versions/a1b8c6d4e2f9_material_items_project_list.py create mode 100644 tests/materials_check.py diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 7202408..5b5de6c 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -292,6 +292,7 @@ Wave 8 adds these: ```bash 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 ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index f9cdb2e..8d05f80 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -1244,7 +1244,6 @@ const LOCATION_SAMPLE = [ ].join('\n'); let _locNodes = []; -let _locLoaded = false; function locProjectId(){ try { @@ -1259,53 +1258,32 @@ function locApi(suffix){ } function locEsc(v){ return escAttr(v); } -// Every message on this step goes through here so the role is decided in one -// place: a report that lost rows interrupts (T4.5), a clean one does not. -function locSay(html, isProblem){ - const el = document.getElementById('loc-report'); - if(!el) return; - el.setAttribute('role', isProblem ? 'alert' : 'status'); - el.innerHTML = html || ''; - el.classList.toggle('is-problem', !!isProblem); -} - -function locSetAddError(msg){ - const el = document.getElementById('loc-add-err'); - if(el) el.textContent = msg || ''; - const input = document.getElementById('loc-add-name'); - if(input){ - if(msg) input.setAttribute('aria-invalid', 'true'); - else input.removeAttribute('aria-invalid'); - } -} - -function locLoad(force){ - const tool = document.getElementById('loc-tool'); - const warn = document.getElementById('loc-noproject'); - const pid = locProjectId(); - if(!pid){ - if(tool) tool.style.display = 'none'; - if(warn){ - warn.style.display = ''; - warn.textContent = 'Open this wizard from a project to configure its location list. ' - + 'The list is stored against the project on the server, not in this browser.'; - } - return Promise.resolve(); - } - if(tool) tool.style.display = ''; - if(warn) warn.style.display = 'none'; - if(_locLoaded && !force) return Promise.resolve(); - return fetch(locApi('?include_inactive=true'), {headers:{'Accept':'application/json'}}) - .then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) - .then(data => { _locNodes = data.nodes || []; _locLoaded = true; locRender(); }) - .catch(err => { - _locLoaded = false; - locRender(); - locSay('⚠ Could not load the location list — ' + locEsc((err && err.message) || 'offline') - + '. It is stored on the server, so nothing local is shown in its place.', true); - }); -} +// D6 / T8.6: the paste-or-file import machinery moved to wp-list-import.js - +// ONE component; the location list and the material list are both instances of +// it. These names survive as thin delegates because the row handlers, the step +// entry and the probes all call them. +const locList = WPListImport({ + prefix: 'loc', api: locApi, projectId: locProjectId, sample: LOCATION_SAMPLE, + loadKey: 'nodes', esc: locEsc, + render: function(){ _locNodes = locList.state.rows; locRender(); }, + rejectedRow: r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `, + duplicateRow: r => `
  • row ${r.line} ${locEsc(r.path)} — ${locEsc(r.reason)}
  • `, + addPayload: function(){ + const name = ((document.getElementById('loc-add-name') || {}).value || '').trim(); + if(!name) return {error: 'Enter a name for the value you are adding.'}; + const parentId = (document.getElementById('loc-add-parent') || {}).value || ''; + const parent = _locNodes.find(n => n.id === parentId); + const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector'); + return {payload: {level: level, parent_id: parentId || null, name: name}}; + }, + addedMessage: body => `Added ${locEsc(body.path)}.`, + noProjectMessage: 'Open this wizard from a project to configure its location list. ' + + 'The list is stored against the project on the server, not in this browser.', +}); +function locSay(html, isProblem){ locList.say(html, isProblem); } +function locSetAddError(msg){ locList.setAddError(msg); } +function locLoad(force){ return locList.load(force); } function locRender(){ const list = document.getElementById('loc-list'); const count = document.getElementById('loc-count'); @@ -1352,130 +1330,14 @@ function locRenderParentPicker(){ if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep; } -function locReport(res){ - const bits = []; - const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0; - const verb = res.dry_run ? 'would be added' : 'added'; - bits.push(`

    ${res.read} row${res.read===1?'':'s'} read. ` - + `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}` - + ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '') - + `.

    `); - const rowList = (title, rows, fmt) => { - if(!rows || !rows.length) return ''; - return `
    ${locEsc(title)} (${rows.length})
    ` - + '
      ' + rows.map(fmt).join('') + '
    '; - }; - bits.push(rowList('Rejected', res.rejected, - r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `)); - bits.push(rowList('Duplicates, not merged', res.duplicates, - r => `
  • row ${r.line} ${locEsc(r.path)} — ${locEsc(r.reason)}
  • `)); - if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){ - bits.push('

    Nothing to do — every row is already on this project.

    '); - } - locSay(bits.join(''), problem); -} - -function locImport(dryRun){ - const text = (document.getElementById('loc-paste') || {}).value || ''; - if(!text.trim()){ - locSay('Paste some rows or choose a CSV file first.', true); - return; - } - if(!locProjectId()){ locLoad(); return; } - locSay('Checking…', false); - fetch(locApi('/import'), { - method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'}, - body: JSON.stringify({text: text, dry_run: !!dryRun}), - }) - .then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j}))) - .then(res => { - if(!res.ok){ - locSay('⚠ Import refused — ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); - return; - } - locReport(res.body); - if(!dryRun) return locLoad(true); - }) - .catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline') - + '. Nothing was imported.', true)); -} - -function locAdd(){ - const nameEl = document.getElementById('loc-add-name'); - const parentEl = document.getElementById('loc-add-parent'); - const name = ((nameEl || {}).value || '').trim(); - if(!name){ - locSetAddError('Enter a name for the value you are adding.'); - if(nameEl) nameEl.focus(); - return; - } - locSetAddError(''); - const parentId = (parentEl || {}).value || ''; - const parent = _locNodes.find(n => n.id === parentId); - const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector'); - fetch(locApi(''), { - method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'}, - body: JSON.stringify({level: level, parent_id: parentId || null, name: name}), - }) - .then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j}))) - .then(res => { - if(!res.ok){ - locSetAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')')); - if(nameEl) nameEl.focus(); - return; - } - if(nameEl) nameEl.value = ''; - locSay(`Added ${locEsc(res.body.path)}.`, false); - return locLoad(true); - }) - .catch(err => locSetAddError('Could not reach the server — ' + ((err && err.message) || 'offline'))); -} - -function locPatch(id, patch, describe){ - return fetch(locApi('/' + encodeURIComponent(id)), { - method: 'PATCH', headers: {'Content-Type':'application/json','Accept':'application/json'}, - body: JSON.stringify(patch), - }) - .then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j}))) - .then(res => { - if(!res.ok){ - locSay('⚠ ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); - return locLoad(true); - } - locSay(describe(res.body), false); - return locLoad(true); - }) - .catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline'), true)); -} +function locImport(dryRun){ locList.importText(dryRun); } +function locAdd(){ locList.add(); } +function locPatch(id, patch, describe){ return locList.patch(id, patch, describe); } document.addEventListener('DOMContentLoaded', function(){ - const paste = document.getElementById('loc-paste'); - const file = document.getElementById('loc-file'); const btn = id => document.getElementById(id); - - if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click()); - if(file) file.addEventListener('change', function(ev){ - const f = ev.target.files && ev.target.files[0]; - if(!f) return; - const reader = new FileReader(); - reader.onload = () => { - // One parser, on the server. Reading the file here and posting its text is - // what stops "what does a blank column mean" having two answers. - if(paste) paste.value = String(reader.result || ''); - locSay(`Read ${locEsc(f.name)}. Check it, then import.`, false); - }; - reader.onerror = () => locSay('⚠ Could not read that file.', true); - reader.readAsText(f); - ev.target.value = ''; - }); - if(btn('loc-check-btn')) btn('loc-check-btn').addEventListener('click', () => locImport(true)); - if(btn('loc-import-btn')) btn('loc-import-btn').addEventListener('click', () => locImport(false)); - if(btn('loc-sample-btn')) btn('loc-sample-btn').addEventListener('click', function(){ - if(paste) paste.value = LOCATION_SAMPLE; - locSay('Sample values loaded into the box — obviously fake, and safe to import ' - + 'while the real list is being collected. Check them, then import.', false); - }); - if(btn('loc-add-btn')) btn('loc-add-btn').addEventListener('click', locAdd); + locList.wire(); + matList.wire(); const addName = btn('loc-add-name'); if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } }); @@ -1961,7 +1823,7 @@ function updateStepUI(){ // The location list lives on the server, so it is fetched when the step is // actually opened rather than on every page load. locLoad() is idempotent and // no-ops once it has the list. - if(currentStep === 11 && typeof locLoad === 'function') locLoad(); + if(currentStep === 11 && typeof locLoad === 'function'){ locLoad(); if(typeof matList !== 'undefined') matList.load(); } if(currentStep === 12 && typeof renderSectionToggles === 'function') renderSectionToggles(); // Update buttons @@ -2105,6 +1967,93 @@ function validateStep(n){ return false; } +// ── PROJECT MATERIAL LIST (D6 / T8.6) ───────────────────────────────────────── +// The CR-005 call, made again for materials: build the upload path now rather +// than wait for the master workbook. Same component as the location list - +// paste or file, dry-run check, rejected rows with source line numbers, and an +// editable list whose entries deactivate rather than delete. The field set is +// deliberately small: description, unit, an optional code. No inventory level, +// no price, no warehouse id - that is the deferred catalog, and it stays deferred. +const MATERIAL_SAMPLE = [ + 'Sample 3/4in EMT,FT,SAMPLE-EMT-075', + 'Sample 1in EMT,FT,SAMPLE-EMT-100', + 'Sample strut channel,FT,SAMPLE-STRUT', + 'Sample junction box 4x4,EA', +].join('\n'); + +function matApi(suffix){ + return '/api/projects/' + encodeURIComponent(locProjectId()) + '/materials' + (suffix || ''); +} + +const matList = WPListImport({ + prefix: 'mat', api: matApi, projectId: locProjectId, sample: MATERIAL_SAMPLE, + loadKey: 'items', esc: locEsc, + render: matRender, + rejectedRow: r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `, + duplicateRow: r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `, + addPayload: function(){ + const name = ((document.getElementById('mat-add-name') || {}).value || '').trim(); + if(!name) return {error: 'Enter a description for the material you are adding.'}; + const unit = ((document.getElementById('mat-add-unit') || {}).value || '').trim(); + return {payload: {description: name, unit: unit, code: ''}}; + }, + addedMessage: body => `Added ${locEsc(body.description)}.`, + noProjectMessage: 'Open this wizard from a project to configure its material list. ' + + 'The list is stored against the project on the server, not in this browser.', +}); + +function matRender(){ + const list = document.getElementById('mat-list'); + const count = document.getElementById('mat-count'); + if(!list) return; + const rows = matList.state.rows; + const active = rows.filter(r => r.active); + if(count){ + count.textContent = rows.length + ? `${active.length} material${active.length===1?'':'s'} in use` + + (rows.length > active.length ? `, ${rows.length - active.length} deactivated` : '') + : ''; + } + if(!rows.length){ + list.innerHTML = '

    No material list yet. Requests fall back to free ' + + 'text until one is loaded - a project with no list can still raise a request.

    '; + return; + } + list.innerHTML = rows.map(r => `
    + ${locEsc(r.unit || '—')} + + ${r.code ? `${locEsc(r.code)}` : ''} + +
    `).join(''); +} + +document.addEventListener('DOMContentLoaded', function(){ + const list = document.getElementById('mat-list'); + if(!list) return; + list.addEventListener('change', function(e){ + const t = e.target; + if(!t) return; + if(t.classList.contains('mat-active')){ + matList.patch(t.dataset.id, {active: t.checked}, + b => b.active + ? `${locEsc(b.description)} is back in use.` + : `${locEsc(b.description)} is no longer offered on new requests. ` + + 'Requests already referencing it are unchanged.'); + return; + } + if(t.classList.contains('mat-desc')){ + const row = matList.state.rows.find(n => n.id === t.dataset.id); + const next = (t.value || '').trim(); + if(!row || next === row.description){ if(row) t.value = row.description; return; } + if(!next){ t.value = row.description; return; } + matList.patch(t.dataset.id, {description: next}, + b => `Renamed to “${locEsc(b.description)}”.`); + } + }); +}); + // ── SOP COMPLETION ──────────────────────────────────────────────────────────── // Re-saving a SOP that is already complete changes the project's baseline, which // the server restricts to a Project Admin. Check before doing the work so the diff --git a/html/work-package-suite.html b/html/work-package-suite.html index 3094481..24ed35a 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -12,6 +12,7 @@ during their own boot. --> + +
    +
    Material list (D6)
    +
    What a material request picks from (CR-013). Optional — + a project with no list still raises requests with free text.
    + +
    +
    +
    + + + One row per material: + description, unit, code — unit and code optional. + Comma, semicolon or tab separated. A header row is ignored. +
    +
    +
    + + + + + +
    +
    +
    +
    Current list
    +

    +
    +
    + + + + + +
    + +
    +
    +