T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off

Marlena's ask: QA sees inbound work ahead of time, not after the fact.

The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders
(wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field
View's list - inside the T7.3 transition model, not beside it: entering it
from an unreleased state crosses the release gates, and 'Issue' (hold) stays
a branch. The dashboard filter and the navigator grouping learned the state
from the ladder without their own edits.

Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP
wizard's team step, stored as account ids at data.sop.project.qaGroupIds.
Entering Ready for QA emails that list and nobody else. A rejection emails
the owner AND the same list (amended answer), returns the package to In
Progress, and REQUIRES a fresh comment - server-enforced on both write paths
(the first version accepted any old comment already on the record, which made
every rejection after the first one free; the gate now demands a new entry).
Accept and reject are real buttons on the release banner; the comment modal
enforces its field; qa_ready / qa_rejected / status_changed all land in the
audit history.

The link (X1): wp_link() now opens THE package - wp-creation-index.html
?project&wp=<id>, which the creator boots directly and login.html?next=
round-trips for a signed-out recipient. It previously pointed at the suite
root, which is exactly the failure X1 names; assignment mail inherits the fix.

Email discipline (D10 + standing rules): ships OFF (the stored setting the
admin console already owns; PUT /api/settings is admin-only, 403 for anyone
else, and audited). With it off, transitions write outbox rows marked
'skipped' and the sink receives nothing. With it on, the probe runs a REAL
SMTP conversation against an in-process capture sink and asserts the count
and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row
with the error recorded. The SMTP password exists only in the environment.

DEVIATION, stated: the task's Do-paragraph asks the email to include location
and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in
a message body. The done-when wins: bodies carry the WP number, who moved it,
and the deep link. A location canary planted on the package is asserted absent
from every captured message. If the fuller body is wanted, that is a product
call - needs Nick.

Found while building, logged not fixed (BL-021): project_sop_team() reads
sop.data['project'], a path pushSOP never writes - the critical-reopen email
has never actually reached the PM/CM. One-line fix, owned by T9.9.

Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px.

Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40.
Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16,
frame_check 39/39, validation_check 83/83.

Items: CR-014, D2, D9, D10 (X1, X3 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:54:07 -07:00
parent 5d27a1e086
commit 2486f87010
10 changed files with 700 additions and 9 deletions

View File

@@ -40,7 +40,10 @@ const SAMPLE_SOP = {
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
// CR-014: 'Ready for QA' is a rung between In Progress and QC - the queue QA
// plans their week from. 'Issue' (hold) stays OUT of this ladder: it is a
// branch, not a rung (T7.3).
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','Ready for QA','QC','Closed'];
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
// Acumatica cost codes (comment 10) — code|description
@@ -62,7 +65,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
let SOP=null, editingId=null, numberDirty=false;
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
@@ -989,6 +992,15 @@ function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(st==='Ready for QA'){
// CR-014: the QA decision lives where the state is announced. Accept moves to
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
// it, and the server refuses the transition without one).
cls='rb-qa';
txt=`◉ In the QA queue — accept to begin QC, or return it to the crew with a comment.`
+` <button type="button" class="rb-act" onclick="qaAccept()">Accept — start QC</button>`
+`<button type="button" class="rb-act rb-act-ghost" onclick="qaRejectOpen()">Reject…</button>`;
}
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
else if(r.constraintsClear){
// Constraints are done; what's left is upstream work.
@@ -1081,6 +1093,32 @@ function releaseHold(clearedName){
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
}
// CR-014: accept / reject, from the banner. Accept is a plain forward step.
// Reject is the audited return: a comment is mandatory, rides on the package
// (data.qaRejections), and the server emails the owner and the QA group.
function qaAccept(){
setRadio('status','QC'); prevStatus='QC';
updateReleaseBanner(); toast('Accepted — package is in QC.');
track('qa_accepted');
}
function qaRejectOpen(){
const el=document.getElementById('qa-reject-comment'); if(el) el.value='';
document.getElementById('qa-reject-modal').classList.add('open');
if(el) el.focus();
}
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
function qaRejectSubmit(){
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
if(!c){ alert('A comment is required — the crew needs to know what to fix.'); return; }
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
from:'Ready for QA' });
document.getElementById('qa-reject-modal').classList.remove('open');
setRadio('status','In Progress'); prevStatus='In Progress';
updateReleaseBanner(); toast('Returned to In Progress — the comment is on the record.');
track('qa_rejected');
}
// D4: the banner's primary action for an Urgent package blocked by constraints.
// It runs the SAME audited path the status control runs - one override, one log.
function urgentOverrideRelease(){
@@ -1253,6 +1291,7 @@ function collectPackage(){
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
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})),
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
// AWP traceability: which BIM/model package(s) enabled this install package.
@@ -2043,7 +2082,7 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// ── WP NAVIGATOR (left rail) ─────────────────────────────────────────────────
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
const WPNAV_ORDER=['Issue','Ready for QA','In Progress','Issued','QC','Scheduled','Draft','Closed'];
// The panel's ESSENTIAL layout ships with this script rather than living only in
// wp-creation-styles.css. The two files are cached independently, so a browser can
// run new markup against an old stylesheet — and without these rules the panel is
@@ -2395,6 +2434,7 @@ function loadPackageIntoForm(p){
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
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}));
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
}
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
@@ -2457,7 +2497,7 @@ function newPackage(){
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps();
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
pkgHolds=[]; pkgOverrides={};
pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={};
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
prevStatus='Draft';
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');