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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
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 <code>${locEsc(body.path)}</code>.`,
|
||||
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(`<p class="loc-report-line"><strong>${res.read} row${res.read===1?'':'s'} read.</strong> `
|
||||
+ `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}`
|
||||
+ ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '')
|
||||
+ `.</p>`);
|
||||
const rowList = (title, rows, fmt) => {
|
||||
if(!rows || !rows.length) return '';
|
||||
return `<div class="loc-report-group"><div class="loc-report-title">${locEsc(title)} (${rows.length})</div>`
|
||||
+ '<ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates,
|
||||
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`));
|
||||
if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
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 <code>${locEsc(res.body.path)}</code>.`, 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 <strong>${locEsc(f.name)}</strong>. 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 => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
|
||||
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 <code>${locEsc(body.description)}</code>.`,
|
||||
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 = '<p class="field-hint">No material list yet. Requests fall back to free '
|
||||
+ 'text until one is loaded - a project with no list can still raise a request.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => `<div class="loc-row${r.active ? '' : ' is-off'}" data-id="${locEsc(r.id)}">
|
||||
<span class="loc-level">${locEsc(r.unit || '—')}</span>
|
||||
<input class="mat-desc" type="text" value="${locEsc(r.description)}"
|
||||
aria-label="Description for ${locEsc(r.code || r.description)}" data-id="${locEsc(r.id)}">
|
||||
${r.code ? `<code class="loc-code">${locEsc(r.code)}</code>` : ''}
|
||||
<label class="loc-toggle"><input type="checkbox" class="mat-active" data-id="${locEsc(r.id)}"
|
||||
${r.active ? 'checked' : ''}><span>In use</span></label>
|
||||
</div>`).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
|
||||
? `<code>${locEsc(b.description)}</code> is back in use.`
|
||||
: `<code>${locEsc(b.description)}</code> 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
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="wp-list-import.js"></script>
|
||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||
<script src="wp-autosave.js"></script>
|
||||
<!-- Which work package sections this project uses (CR-006). Shared with the
|
||||
@@ -466,6 +467,51 @@
|
||||
<div class="field-error" id="loc-add-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MATERIAL LIST (D6 / T8.6). Same component as the location list
|
||||
above (wp-list-import.js), same rules: paste or file, dry-run
|
||||
check, rejected rows named by source line, deactivate not
|
||||
delete. Description, unit, optional code - and nothing else:
|
||||
no inventory, no pricing, no warehouse id. -->
|
||||
<div style="margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Material list (D6)</div>
|
||||
<div class="step-desc">What a material request picks from (CR-013). Optional —
|
||||
a project with no list still raises requests with free text.</div>
|
||||
<div id="mat-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
<div id="mat-tool">
|
||||
<div class="field-grid col1">
|
||||
<div class="field">
|
||||
<label for="mat-paste">Paste rows, or upload a CSV</label>
|
||||
<textarea id="mat-paste" rows="5" aria-describedby="mat-paste-hint"
|
||||
placeholder="One row per material, e.g. Description, unit, code"></textarea>
|
||||
<small id="mat-paste-hint">One row per material:
|
||||
<strong>description, unit, code</strong> — unit and code optional.
|
||||
Comma, semicolon or tab separated. A header row is ignored.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loc-actions">
|
||||
<input type="file" id="mat-file" accept=".csv,.txt,text/csv,text/plain" hidden>
|
||||
<button type="button" class="add-btn" id="mat-file-btn">Choose a CSV file…</button>
|
||||
<button type="button" class="add-btn" id="mat-check-btn">Check without importing</button>
|
||||
<button type="button" class="add-btn" id="mat-import-btn">Import</button>
|
||||
<button type="button" class="add-btn" id="mat-sample-btn">Load sample values</button>
|
||||
</div>
|
||||
<div class="loc-report" id="mat-report" role="status"></div>
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<div class="sub-heading">Current list</div>
|
||||
<p class="field-hint" id="mat-count"></p>
|
||||
<div id="mat-list"></div>
|
||||
<div class="loc-addrow">
|
||||
<label class="loc-addlabel" for="mat-add-name">Description</label>
|
||||
<input type="text" id="mat-add-name" placeholder="e.g. a strut channel">
|
||||
<label class="loc-addlabel" for="mat-add-unit">Unit</label>
|
||||
<input type="text" id="mat-add-unit" placeholder="EA / FT" style="max-width:90px">
|
||||
<button type="button" class="add-btn" id="mat-add-btn">+ Add</button>
|
||||
</div>
|
||||
<div class="field-error" id="mat-add-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 12: WORK PACKAGE SECTIONS (CR-006)
|
||||
|
||||
210
html/wp-list-import.js
Normal file
210
html/wp-list-import.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6).
|
||||
|
||||
One implementation of paste-or-file → server-side import with dry-run →
|
||||
a report that names every rejected row with its SOURCE line number → an
|
||||
editable list whose entries deactivate rather than delete. The location list
|
||||
(CR-005) and the material list (D6) are both instances of this; building the
|
||||
material path "the same way and against the same component, not beside it"
|
||||
is the T8.6 instruction, and extracting the component is what makes that
|
||||
literally true rather than a copy with the names changed.
|
||||
|
||||
The page supplies what differs: the API base, the sample, how a row renders,
|
||||
and what the add-row collects. Everything generic — the file reader feeding
|
||||
the paste box (one parser, on the server), the dry-run wiring, the report
|
||||
roles (a report that lost rows interrupts; a clean one does not, per T4.5) —
|
||||
lives here once.
|
||||
|
||||
Classic script, no modules: exposes window.WPListImport. */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
window.WPListImport = function (cfg) {
|
||||
// cfg.prefix DOM id prefix: '<p>-paste', '<p>-file', '<p>-file-btn',
|
||||
// '<p>-check-btn', '<p>-import-btn', '<p>-sample-btn',
|
||||
// '<p>-report', '<p>-add-name', '<p>-add-btn', '<p>-add-err',
|
||||
// '<p>-tool', '<p>-noproject'
|
||||
// cfg.api(suffix) URL builder for the project-scoped routes
|
||||
// cfg.projectId() current project id ('' = not opened from a project)
|
||||
// cfg.sample text the sample button loads
|
||||
// cfg.loadKey response key holding the rows ('nodes' | 'items')
|
||||
// cfg.render() paints the current list from state.rows
|
||||
// cfg.rejectedRow(r) <li> HTML for one rejected row
|
||||
// cfg.duplicateRow(r) <li> HTML for one duplicate row
|
||||
// cfg.addPayload() reads the add-row; {payload} to POST or {error}
|
||||
// cfg.addedMessage(body) confirmation HTML after a successful add
|
||||
// cfg.noProjectMessage what to say when there is no project
|
||||
// cfg.esc the page's escaper
|
||||
var p = cfg.prefix;
|
||||
var esc = cfg.esc;
|
||||
var state = { rows: [], loaded: false };
|
||||
|
||||
// Every message 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 say(html, isProblem) {
|
||||
var box = el(p + '-report');
|
||||
if (!box) return;
|
||||
box.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||
box.innerHTML = html || '';
|
||||
box.classList.toggle('is-problem', !!isProblem);
|
||||
}
|
||||
|
||||
function setAddError(msg) {
|
||||
var box = el(p + '-add-err');
|
||||
if (box) box.textContent = msg || '';
|
||||
var input = el(p + '-add-name');
|
||||
if (input) {
|
||||
if (msg) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function load(force) {
|
||||
var tool = el(p + '-tool');
|
||||
var warn = el(p + '-noproject');
|
||||
var pid = cfg.projectId();
|
||||
if (!pid) {
|
||||
if (tool) tool.style.display = 'none';
|
||||
if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; }
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (tool) tool.style.display = '';
|
||||
if (warn) warn.style.display = 'none';
|
||||
if (state.loaded && !force) return Promise.resolve();
|
||||
return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); })
|
||||
.catch(function (err) {
|
||||
state.loaded = false;
|
||||
cfg.render();
|
||||
say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline')
|
||||
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function report(res) {
|
||||
var bits = [];
|
||||
var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||
var verb = res.dry_run ? 'would be added' : 'added';
|
||||
var nCreated = (res.created || []).length;
|
||||
bits.push('<p class="loc-report-line"><strong>' + res.read + ' row' + (res.read === 1 ? '' : 's')
|
||||
+ ' read.</strong> ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb
|
||||
+ ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '')
|
||||
+ '.</p>');
|
||||
var rowList = function (title, rows, fmt) {
|
||||
if (!rows || !rows.length) return '';
|
||||
return '<div class="loc-report-group"><div class="loc-report-title">' + esc(title)
|
||||
+ ' (' + rows.length + ')</div><ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow));
|
||||
if (!problem && !nCreated && !(res.reactivated || []).length) {
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
if (!cfg.projectId()) { load(); return; }
|
||||
say('Checking…', false);
|
||||
fetch(cfg.api('/import'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return;
|
||||
}
|
||||
report(res.body);
|
||||
if (!dryRun) return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline')
|
||||
+ '. Nothing was imported.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function add() {
|
||||
var read = cfg.addPayload();
|
||||
if (read.error) {
|
||||
setAddError(read.error);
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
setAddError('');
|
||||
fetch(cfg.api(''), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.value = '';
|
||||
say(cfg.addedMessage(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); });
|
||||
}
|
||||
|
||||
function patch(id, patchBody, describe) {
|
||||
return fetch(cfg.api('/' + encodeURIComponent(id)), {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return load(true);
|
||||
}
|
||||
say(describe(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true);
|
||||
});
|
||||
}
|
||||
|
||||
function wire() {
|
||||
var paste = el(p + '-paste');
|
||||
var file = el(p + '-file');
|
||||
var btn = function (suffix) { return el(p + suffix); };
|
||||
if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); });
|
||||
if (file) file.addEventListener('change', function (ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
// 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 || '');
|
||||
say('Read <strong>' + esc(f.name) + '</strong>. Check it, then import.', false);
|
||||
};
|
||||
reader.onerror = function () { say('⚠ Could not read that file.', true); };
|
||||
reader.readAsText(f);
|
||||
ev.target.value = '';
|
||||
});
|
||||
if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); });
|
||||
if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); });
|
||||
if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () {
|
||||
if (paste) paste.value = cfg.sample;
|
||||
say('Sample values loaded into the box — obviously fake, and safe to import '
|
||||
+ 'on a throwaway project.', false);
|
||||
});
|
||||
if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add);
|
||||
}
|
||||
|
||||
return { state: state, say: say, setAddError: setAddError, load: load,
|
||||
importText: importText, add: add, patch: patch, wire: wire };
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
"""per-project material list (D6 / T8.6)
|
||||
|
||||
CR-013 was written to accept free text because Nate's spreadsheet and the
|
||||
master material workbook had not been supplied - and they still have not.
|
||||
The Aug 18 call was the same one made for locations at CR-005: build the
|
||||
upload path now. One row per line item a request can pick from: description,
|
||||
unit, an optional code. Deliberately NO inventory level, price or warehouse
|
||||
id - a project-scoped uploaded list is not the deferred parts catalog.
|
||||
|
||||
Additive only: a new table, no change to any existing one.
|
||||
|
||||
Revision ID: a1b8c6d4e2f9
|
||||
Revises: f3a9d2c1e8b7
|
||||
Create Date: 2026-08-19 15:40:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b8c6d4e2f9'
|
||||
down_revision = 'f3a9d2c1e8b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'material_items',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_material_items_project_id', 'material_items', ['project_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_material_items_project_id', table_name='material_items')
|
||||
op.drop_table('material_items')
|
||||
165
server/app.py
165
server/app.py
@@ -2436,6 +2436,24 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
return result
|
||||
|
||||
|
||||
class MaterialIn(BaseModel):
|
||||
description: str = ""
|
||||
unit: str = ""
|
||||
code: str = ""
|
||||
|
||||
|
||||
class MaterialPatchIn(BaseModel):
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class MaterialImportIn(BaseModel):
|
||||
text: str = ""
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class LocationIn(BaseModel):
|
||||
level: str = "building"
|
||||
parent_id: Optional[str] = None
|
||||
@@ -2873,6 +2891,153 @@ def delete_wp_file(file_id: str, user: models.User = Depends(auth.get_current_us
|
||||
return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING}
|
||||
|
||||
|
||||
# ── Project material list (D6 / T8.6) - the CR-005 pattern, for materials ─────
|
||||
def parse_material_rows(text: str):
|
||||
"""description[,unit[,code]] per row - comma, semicolon or tab separated, a
|
||||
paste straight out of a spreadsheet. A header row is ignored. Rejections come
|
||||
back with the SOURCE line number: an import that says '42 rows' over a file
|
||||
with 50 has lost eight and told nobody."""
|
||||
rows, rejected = [], []
|
||||
header_words = ("description", "desc", "item", "material")
|
||||
for i, raw in enumerate((text or "").split(chr(10)), start=1):
|
||||
# rstrip only: a LEADING separator means the first column is empty, and
|
||||
# the first column is the description - eating it would accept ",FT" as
|
||||
# a material named FT (found by the probe on the first run).
|
||||
line = raw.strip().rstrip(",;")
|
||||
if not line:
|
||||
continue
|
||||
parts = [p.strip() for p in re.split(r"[,;\t]", line)]
|
||||
if i == 1 and parts and parts[0].lower() in header_words:
|
||||
continue
|
||||
parts = [p for p in parts]
|
||||
if not parts or not parts[0]:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "no description in the first column"})
|
||||
continue
|
||||
if len(parts) > 3:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
|
||||
def material_key(desc: str, code: str) -> str:
|
||||
return (code or "").strip().lower() or location_slug(desc).lower()
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/materials")
|
||||
def list_materials(project_id: str, include_inactive: bool = Query(False),
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, project_id)
|
||||
stmt = select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(models.MaterialItem.active.is_(True))
|
||||
rows = db.scalars(stmt.order_by(models.MaterialItem.sort, models.MaterialItem.description)).all()
|
||||
return {"items": [r.to_dict() for r in rows]}
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/materials/import")
|
||||
def import_materials(project_id: str, body: MaterialImportIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not db.get(models.Project, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
rows, rejected = parse_material_rows(body.text)
|
||||
existing = {material_key(r.description, r.code): r for r in db.scalars(
|
||||
select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)).all()}
|
||||
created, duplicates, reactivated = [], [], []
|
||||
seen_in_file = {}
|
||||
next_sort = max((r.sort for r in existing.values()), default=0)
|
||||
for line_no, parts in rows:
|
||||
desc = parts[0][:300]
|
||||
unit = (parts[1] if len(parts) > 1 else "")[:20].upper()
|
||||
code = (parts[2] if len(parts) > 2 else "")[:80]
|
||||
key = material_key(desc, code)
|
||||
if key in seen_in_file:
|
||||
duplicates.append({"line": line_no, "text": desc,
|
||||
"reason": "already on line %d of this import" % seen_in_file[key]})
|
||||
continue
|
||||
seen_in_file[key] = line_no
|
||||
if key in existing:
|
||||
row = existing[key]
|
||||
if not row.active:
|
||||
if not body.dry_run:
|
||||
row.active = True
|
||||
reactivated.append({"text": desc})
|
||||
else:
|
||||
duplicates.append({"line": line_no, "text": desc,
|
||||
"reason": "already on this project"})
|
||||
continue
|
||||
next_sort += 1
|
||||
created.append({"description": desc, "unit": unit, "code": code})
|
||||
if not body.dry_run:
|
||||
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
|
||||
code=code, description=desc, unit=unit,
|
||||
active=True, sort=next_sort)
|
||||
db.add(item)
|
||||
existing[key] = item
|
||||
if not body.dry_run:
|
||||
log_event(db, user, "materials_imported", "project", project_id,
|
||||
project_id=project_id, summary="material list",
|
||||
detail={"created": len(created), "rejected": len(rejected)})
|
||||
db.commit()
|
||||
return {"read": len(rows) + len(rejected), "created": created,
|
||||
"rejected": rejected, "duplicates": duplicates,
|
||||
"reactivated": reactivated, "dry_run": body.dry_run}
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/materials")
|
||||
def add_material(project_id: str, body: MaterialIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not db.get(models.Project, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
desc = (body.description or "").strip()
|
||||
if not desc:
|
||||
raise HTTPException(status_code=400, detail="A description is required.")
|
||||
key = material_key(desc, body.code)
|
||||
clash = [r for r in db.scalars(select(models.MaterialItem)
|
||||
.where(models.MaterialItem.project_id == project_id)).all()
|
||||
if material_key(r.description, r.code) == key]
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail="That material is already on this project.")
|
||||
next_sort = (db.scalar(select(func.coalesce(func.max(models.MaterialItem.sort), 0))
|
||||
.where(models.MaterialItem.project_id == project_id)) or 0) + 1
|
||||
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
|
||||
code=(body.code or "").strip()[:80],
|
||||
description=desc[:300],
|
||||
unit=(body.unit or "").strip()[:20].upper(),
|
||||
active=True, sort=next_sort)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@app.patch("/api/projects/{project_id}/materials/{item_id}")
|
||||
def patch_material(project_id: str, item_id: str, body: MaterialPatchIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.get(models.MaterialItem, item_id)
|
||||
if not row or row.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Material not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
if body.description is not None:
|
||||
row.description = body.description.strip()[:300]
|
||||
if body.unit is not None:
|
||||
row.unit = body.unit.strip()[:20].upper()
|
||||
if body.code is not None:
|
||||
row.code = body.code.strip()[:80]
|
||||
if body.active is not None:
|
||||
# Deactivate, never delete - a request already referencing the line must
|
||||
# keep rendering it (the CR-005 rule, applied to materials).
|
||||
row.active = bool(body.active)
|
||||
db.commit()
|
||||
return row.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/audit")
|
||||
def list_audit(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
|
||||
@@ -347,6 +347,32 @@ class AppSetting(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class MaterialItem(Base):
|
||||
"""One line of a project's material list - D6 / T8.6, the CR-005 call made
|
||||
again: build the upload path now rather than wait for the master workbook.
|
||||
Deliberately small: description, unit, an optional code. NO inventory level,
|
||||
NO price, NO warehouse id - a project-scoped list the project uploaded is
|
||||
not the deferred parts catalog, and the moment a stock count appears here it
|
||||
has crossed the line IMPLEMENTATION.md section 7 draws. `active` rather than
|
||||
delete, same as everything else: a request already referencing a line must
|
||||
keep rendering it."""
|
||||
__tablename__ = "material_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
code: Mapped[str] = mapped_column(String(80), default="")
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
unit: Mapped[str] = mapped_column(String(20), default="")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "project_id": self.project_id, "code": self.code,
|
||||
"description": self.description, "unit": self.unit,
|
||||
"active": self.active, "sort": self.sort}
|
||||
|
||||
|
||||
class WpFile(Base):
|
||||
"""CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here,
|
||||
in the same database as everything else - settled Aug 18: a backup that
|
||||
|
||||
@@ -344,8 +344,18 @@ def run(page, base, tok):
|
||||
if not ln.strip().startswith(("//", "*", "/*")))
|
||||
chk("...and never touches localStorage", "localStorage" not in code_only,
|
||||
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
|
||||
# Re-pointed at T8.6, not relaxed: the fetches moved into the shared list
|
||||
# component (wp-list-import.js) when the material list was built "against
|
||||
# the same component" (D6). The proposition is unchanged - every read and
|
||||
# write reaches the server - so it is asserted where the fetches now live,
|
||||
# plus the wiring that sends THIS list's traffic there.
|
||||
comp = open(os.path.join(ROOT, "html", "wp-list-import.js"), encoding="utf-8").read()
|
||||
comp_code = chr(10).join(ln for ln in comp.splitlines()
|
||||
if not ln.strip().startswith(("//", "*", "/*")))
|
||||
chk("...reaching the server for every read and write",
|
||||
block.count("fetch(locApi") >= 4, block.count("fetch(locApi"))
|
||||
"api: locApi" in block and comp_code.count("fetch(cfg.api") >= 4
|
||||
and "localStorage" not in comp_code,
|
||||
(block.count("api: locApi"), comp_code.count("fetch(cfg.api")))
|
||||
|
||||
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
|
||||
# so any of these appearing as a location value would be a guess presented as
|
||||
|
||||
169
tests/materials_check.py
Normal file
169
tests/materials_check.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the material list upload the way locations do? — D6, T8.6.
|
||||
|
||||
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. Same component as
|
||||
the location list (wp-list-import.js - grep asserts there is exactly one),
|
||||
same rules: paste or file, dry-run check, rejected rows named with their source
|
||||
line, deactivate never delete. Description, unit, optional code - and the probe
|
||||
greps that NO inventory, pricing or stock field crossed the line
|
||||
IMPLEMENTATION.md section 7 draws.
|
||||
|
||||
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 qa_gate_check import api # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
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 main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
# ── 1. one component, no forbidden fields ─────────────────────────────────
|
||||
print("\n1. grep: one component, the lightweight scope")
|
||||
suite = open(os.path.join(HTML, "work-package-suite-app.js"), encoding="utf-8").read()
|
||||
comp_defs = [n for n in os.listdir(HTML) if n.endswith(".js")
|
||||
and "window.WPListImport" in open(os.path.join(HTML, n), encoding="utf-8").read()]
|
||||
chk("the import component is defined once, in wp-list-import.js",
|
||||
comp_defs == ["wp-list-import.js"], ascii_(comp_defs))
|
||||
chk("locations and materials are both instances of it - not a copy beside it",
|
||||
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
|
||||
and "function locImport(dryRun){ locList.importText" in suite)
|
||||
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
||||
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
|
||||
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
|
||||
chk("no inventory, stock, price or warehouse column exists on the model - "
|
||||
"the lightweight scope, exactly",
|
||||
cols and not any(re.search(r"stock|inventor|price|on_hand|warehouse", c, re.I)
|
||||
for c in cols), ascii_(cols))
|
||||
chk("the sample rows are obviously fake",
|
||||
"SAMPLE-EMT" in suite and "Sample " in suite)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mat-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
# ── 2. the server: import, report, edit, deactivate ──────────────────
|
||||
print("\n2. the routes")
|
||||
text = "description,unit,code\nSample 3/4in EMT,FT,S-EMT\n,FT\nSample strut,FT\nSample 3/4in EMT,FT,S-EMT\n"
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": True})
|
||||
chk("a dry run reports without writing",
|
||||
code == 200 and rep["dry_run"] and len(rep["created"]) == 2, ascii_(rep))
|
||||
chk("...rejected rows carry the SOURCE line and a reason",
|
||||
rep["rejected"] and rep["rejected"][0]["line"] == 3
|
||||
and "description" in rep["rejected"][0]["reason"], ascii_(rep["rejected"]))
|
||||
chk("...in-file duplicates are named with the line they repeat",
|
||||
rep["duplicates"] and "line 2" in rep["duplicates"][0]["reason"],
|
||||
ascii_(rep["duplicates"]))
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("...and nothing was written", listing["items"] == [])
|
||||
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": False})
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("the real import lands both good rows", len(listing["items"]) == 2,
|
||||
ascii_(listing))
|
||||
item = listing["items"][0]
|
||||
code, _ = api(base, "/api/projects/projA/materials/%s" % item["id"], root,
|
||||
"PATCH", {"active": False})
|
||||
_, act = api(base, "/api/projects/projA/materials", root)
|
||||
_, allrows = api(base, "/api/projects/projA/materials?include_inactive=true", root)
|
||||
chk("deactivating hides a line from new requests without deleting it",
|
||||
code == 200 and len(act["items"]) == 1 and len(allrows["items"]) == 2)
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample 3/4in EMT,FT,S-EMT", "dry_run": False})
|
||||
chk("re-importing a deactivated line brings back the SAME row",
|
||||
code == 200 and len(rep["reactivated"]) == 1, ascii_(rep))
|
||||
code, added = api(base, "/api/projects/projA/materials", root, "POST",
|
||||
{"description": "Sample anchor", "unit": "ea"})
|
||||
chk("hand-adding works, unit normalised", code == 200 and added["unit"] == "EA")
|
||||
|
||||
# ── 3. the wizard at 390px ────────────────────────────────────────────
|
||||
print("\n3. the wizard, on a phone")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(390, 844, mobile=True)
|
||||
page.goto(base + "/work-package-suite.html?tab=sop&project=projA")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
page.eval("currentStep = 11; updateStepUI(); if(typeof matList!=='undefined') matList.load(); locLoad();")
|
||||
settle(1.2)
|
||||
chk("step 11 offers the material list beside the location list",
|
||||
page.eval("!!document.getElementById('mat-tool') && !!document.getElementById('loc-tool')"))
|
||||
chk("...its current list renders the server rows",
|
||||
"Sample strut" in json.loads(page.eval(
|
||||
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
|
||||
".map(i=>i.value))")), ascii_(json.loads(page.eval(
|
||||
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
|
||||
".map(i=>i.value))"))))
|
||||
chk("...and nothing scrolls sideways at 390px",
|
||||
page.eval("document.documentElement.scrollWidth <= 392"),
|
||||
page.eval("document.documentElement.scrollWidth"))
|
||||
page.eval("document.getElementById('mat-paste').value='Sample wire nut,EA'")
|
||||
page.eval("document.getElementById('mat-import-btn').click()")
|
||||
settle(1.2)
|
||||
chk("an import through the UI lands and reports",
|
||||
"1 value added" in page.eval(
|
||||
"(document.getElementById('mat-report')||{textContent:''}).textContent"),
|
||||
ascii_(page.eval("(document.getElementById('mat-report')||{textContent:''}).textContent")))
|
||||
|
||||
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:
|
||||
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