diff --git a/README.md b/README.md index feac97a..077a9f2 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,8 @@ The detail lives in dedicated documents: - [Factory console and order status](docs/factory-console.html): the factory application of the [digital factory](docs/digital-factory.md) plan as a demo on a simulated plant day: the press floor with live - counters, the inspection stream, audio samples, the material lab gate, + counters, work orders per station (kiosk steps: badge in, scan, + confirm), the inspection stream, audio samples, the material lab gate, stamper metrology, the hash-chained event log, and the customer portal for the same orders (steps, copies pressed so far, lab and inspection results, approving a test pressing) diff --git a/docs/digital-factory.md b/docs/digital-factory.md index afea182..eff9c2f 100644 --- a/docs/digital-factory.md +++ b/docs/digital-factory.md @@ -495,8 +495,9 @@ VictoriaMetrics. Packaged as a flake so the clan repo pins it as an input. **Demo.** [Factory console and order status](factory-console.html) (test site `/demos/factory/`) shows what the application would look like on a -simulated plant day: the press floor with live counters per press, the -optical inspection stream, audio samples, the material lab gate with a +simulated plant day: the press floor with live counters per press, work +orders per station in the kiosk form described below (badge in, scan +what the step asks for, confirm), the optical inspection stream, audio samples, the material lab gate with a lot on hold, stamper metrology and genealogy, the hash-chained event log, and the customer portal of the same orders (progress through the three gates, copies pressed so far, the lab certificate of the compound lot, diff --git a/docs/factory-console.html b/docs/factory-console.html index 9223493..a2d97b6 100644 --- a/docs/factory-console.html +++ b/docs/factory-console.html @@ -224,6 +224,42 @@ document.documentElement.setAttribute("data-theme",t);})(); .daysv b { font-family: var(--mono); font-weight: 600; color: var(--ink); font-size: 11px; } .verdict { font-weight: 650; white-space: nowrap; } + /* work orders */ + .kan { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: start; } + .kcol { background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; padding: 10px; min-width: 0; } + .kcol > h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 2px 4px 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mute); } + .kcol > h3 b { font-family: var(--mono); color: var(--ink); } + .wo { display: flex; flex-direction: column; gap: 4px; background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--stroke); border-radius: 11px; padding: 9px 11px; margin-bottom: 8px; color: var(--ink); text-decoration: none; min-width: 0; } + .wo:hover { border-color: var(--stroke); text-decoration: none; } + .wo.doing { border-left-color: var(--acc); } + .wo.todo { border-left-color: var(--inv); } + .wo.done { border-left-color: var(--ok); opacity: 0.86; } + .wo-h { display: flex; align-items: center; gap: 8px; font-size: 11.5px; min-width: 0; } + .wo-h .who { margin-left: auto; color: var(--mute); white-space: nowrap; } + .wo b { font-size: 13.5px; font-weight: 650; } + .wo .rf { font-size: 12px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .wo small { color: var(--mute); } + .wo small.cs { color: var(--ink); } + .kgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 16px; align-items: start; } + ol.ks { list-style: none; margin: 0; padding: 0; counter-reset: ks; } + ol.ks li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 10px; border-radius: 12px; border: 1px solid transparent; margin-bottom: 4px; } + ol.ks li .n { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; border: 2px solid var(--stroke); color: var(--mute); } + ol.ks li b { display: block; font-weight: 620; font-size: 15px; line-height: 1.3; } + ol.ks li small { color: var(--mute); } + ol.ks li.done .n { background: var(--ok); border-color: var(--ok); color: #fff; } + ol.ks li.done b { color: var(--mute); font-weight: 500; } + ol.ks li.now { background: var(--acc-s); border-color: color-mix(in srgb, var(--acc) 40%, transparent); } + ol.ks li.now .n { border-color: var(--acc); color: var(--acc); } + ol.ks li.next b { color: var(--mute); font-weight: 500; } + .scan { font-family: var(--mono); font-size: 11.5px; border: 1px dashed var(--stroke); border-radius: 8px; padding: 3px 8px; color: var(--mute); white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; } + .scan::before { content: "▦ "; } + li.now .scan { border-color: var(--acc); color: var(--ink); } + .wol { display: flex; flex-direction: column; } + .wol a { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); color: var(--ink); font-size: 13px; text-decoration: none; } + .wol a:last-child { border-bottom: 0; } + .wol a:hover b { text-decoration: underline; } + .wol b { font-weight: 560; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* disc */ .disc .hole { fill: var(--bg); } .disc .rot { animation: rot var(--rpm, 1.8s) linear infinite; } @@ -289,6 +325,7 @@ document.documentElement.setAttribute("data-theme",t);})(); .sh2 { font-size: 17px; font-weight: 700; margin: 26px 0 12px; } @media (max-width: 1100px) { + .kan, .kgrid { grid-template-columns: minmax(0, 1fr); } .cols2 { grid-template-columns: minmax(0, 1fr); } .cgrid { grid-template-columns: minmax(0, 1fr); } } @@ -316,6 +353,10 @@ document.documentElement.setAttribute("data-theme",t);})(); .fr .fo { display: none; } .pipe a { grid-template-columns: 120px minmax(0, 1fr) 34px; } .pareto div { grid-template-columns: 112px minmax(0, 1fr) 40px; } + ol.ks li { grid-template-columns: 30px minmax(0, 1fr); } + ol.ks li .scan { grid-column: 2; justify-self: start; max-width: 100%; } + .wol a { grid-template-columns: minmax(0, 1fr) auto; } + .wol a .mono { display: none; } .demobar { font-size: 11px; } } @@ -424,7 +465,7 @@ const NOW0=(()=>{const d=new Date();d.setHours(10,5,0,0);return d.getTime();})() /* ================= state ================= */ const S={t:NOW0-60*DAY,speed:20,run:true,warm:true,orders:[],byId:{},presses:[],lots:[],use:{black:null,clear:null},pend:{black:0,clear:0},regrind:[],feed:[],aud:[],led:[],ledN:0,day:{k:0},days:[],nextArr:0,seq:{o:1040,p:300,lot:2400,rg:880}, ui:{view:"factory",fpage:"floor",arg:null,fhash:"floor",filter:"active",q:"",cust:null}}; -function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0};} +function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0,stops:[]};} /* demo fleet for now: one Duplex system (two presses on one extruder set) and one Classic */ S.presses=[mkPress("D1-A","Duplex","duplex"),mkPress("D1-B","Duplex","duplex"),mkPress("C1","Classic","classic")]; const pById=id=>S.presses.find(p=>p.id===id); @@ -596,7 +637,7 @@ function cycle(p,now){ mount(p,o,now);p.st="change";p.why="Stamper change to set "+(o.setIdx+1);p.until=now+20*MIN;p.next=p.until;p.since=0; log(now,"press",`${p.id}: stamper set ${set.n} retired after ${fI(set.plays)} plays`,p.op||"Operator",o.id);return; } - if(rnd()<1/480){p.st="stop";p.why=pick(STOPS);p.until=now+U(15,45)*MIN;p.next=p.until;log(now,"press",`${p.id} stopped: ${p.why}`,"PLC",o.id);return;} + if(rnd()<1/480){p.st="stop";p.why=pick(STOPS);p.until=now+U(15,45)*MIN;p.next=p.until;p.stops.push({t:now,why:p.why,until:p.until,order:o.id});if(p.stops.length>12)p.stops.shift();log(now,"press",`${p.id} stopped: ${p.why}`,"PLC",o.id);return;} p.next=now+cycMs(p); } function sample(o,p,set,now,key,first){ @@ -783,8 +824,8 @@ function feedHTML(list,n){if(!list.length)return '
The presses ar const kpi=(l,v,s,c)=>`
Showing the latest 60 of ${fI(S.ledN)} events since the simulation started. The demo uses a short hash; the factory application uses SHA-256.
`}) }; +/* ================= work orders: derived from the same records the stations write ================= */ +const WST=[["mast","Mastering suite","g-pre"],["cut","Cutting room","g-metal"],["galv","Galvanics","g-metal"],["prep","Stamper prep","g-metal"],["press","Press floor","g-press"],["qc","Audio QC","g-out"],["lab","Material lab","g-pre"],["pack","Packing","g-out"],["ship","Dispatch","g-out"],["maint","Maintenance","g-gate"]]; +const WSTN=Object.fromEntries(WST.map(x=>[x[0],x])); +const STG_WO={mast:"mast",cut:"cut",plate:"galv",prep:"prep",tp:"press",run:"press",fin:"pack",ship:"ship"}; +const WO_LOG={mast:"session log, engineer, chain settings, reference files for gate 2",cut:"lacquer IDs, microscope result per side, run-out text and fiducial, stylus, operator and time",galv:"part IDs with their parents, bath and cell, XRF thickness, listening check of the mother, archive position",prep:"stamper metrology on four zones, hardness on the centre slug, centring, rack position",press:"press, stamper set, label lot, compound lot, every cycle's telemetry and every optical result",qc:"match against the master, clicks per side, noise floor, channel balance, verdict",lab:"result per instrument, certificate against the specification, release or hold",pack:"assembly scan per copy, barcode grade, carton IDs",ship:"cartons, weights, carrier and tracking",maint:"fault, fix, downtime, test cycle"}; +/* who did it: engineers for mastering and cutting, numbered staff elsewhere; work finished at night is booked to the late shift */ +function opAt(key,t,st){ + if(!inShift(t)){if(!st)return "next shift";const d=new Date(t);if(d.getHours()<6)d.setDate(d.getDate()-1);d.setHours(21,0,0,0);t=d.getTime();} + const h=new Date(t).getHours(),k=fnv(key+":"+dayKey(t)+":"+(h<14?1:2),3); + return st==="mast"?"Mastering engineer":st==="cut"?"Cutting engineer":st==="lab"?"Lab tech "+(k%2+1):st==="qc"?"QC tech "+(k%2+1):st==="maint"?"Maintenance tech "+(k%2+1):"Operator "+pad(2+k%13);} +function stIds(o,k){const s=o.sets[k||0];return s?s.st.map(x=>x.id).join(", "):"";} +function stageSteps(o,stId){ + const n=o.sets.length,ids=a=>a?a.map(x=>x.id).join(", "):""; + switch(stId){ + case "mast":return [["Badge in at the mastering suite","badge"],["Load the approved files","checksums"],["Side plan: order, gaps, level against side length"],["Master side A"],["Master side B"],["Reference files to the customer for gate 2"]]; + case "cut":return [["Badge in at Lathe 1","badge"],["Scan two lacquer blanks",ids(o.lac)||"blank IDs"],["Load the cutting plan from the approved master"],["Cut side A"],["Microscope check, side A"],["Cut side B"],["Microscope check, side B"],["Scribe the run-out and the fiducial",o.catno+" A / B"],["Pack, hand to galvanics the same day"]]; + case "plate":return o.repress?[["Scan the archived mothers",ids(o.mo)],["Mothers into "+(o.cell||"the cell")],["Grow "+n+" stamper set"+(n>1?"s":"")],["Separate: laser back marks, XRF thickness",stIds(o)],["Hand over to stamper prep"]] + :[["Scan the lacquers",ids(o.lac)],["Silver the lacquers"],["Father into "+(o.cell||"the bath")],["Separate the father: laser back mark, XRF",ids(o.fa)],["Mother into the bath"],["Separate the mother: listening check, archive",ids(o.mo)],["Grow "+n+" stamper set"+(n>1?"s":"")],["Separate the stampers: laser back marks, XRF",stIds(o)],["Hand over to stamper prep"]]; + case "prep":return [["Scan each stamper's Data Matrix",stIds(o)],["Back-finish (Sibert VSF)"],["Punch the centre (Sibert VSP)"],["Coin the edge (Sibert VSC)"],["Confocal groove check on four zones"],["Hardness on the centre slug"],["Centring check"],["Into the rack, position logged"]]; + case "tp":return [["Scan stamper set 1",stIds(o)],["Scan the label lot and the compound lot"],["Mount and heat up"],["Press 10 test pressings"],["Optical check and audio comparison"],["Pack 5 for the customer, courier out"]]; + case "run":{const k=Math.min(o.setIdx,Math.max(0,o.sets.length-1));return [["Badge in at the press","badge"],["Scan stamper set "+(k+1),stIds(o,k)],["Scan the label lot",o.labelLot||"label lot"],["Confirm the compound lot in the hopper",o.lots.slice(-1)[0]||"lot ID"],["Mount the stampers, heat up"],["First-off: optical pass, copy to audio QC"],["Press "+fI(o.qty)+" good copies"],["Unmount, stampers to the rack"]];} + case "fin":return [["Scan the order",o.id],["Sleeve and jacket each copy"],["Assembly scan: run-out code against the jacket barcode","each copy"],["Barcode verification on every unit"],...(o.shrink?[["Shrink wrap"]]:[]),["Cartons, packing list, label"]]; + case "ship":return [["Pick the cartons",o.ship?o.ship.boxes+" cartons":""],["Weigh, print the carrier label"],["Hand over to "+(o.ship?o.ship.carrier:"the carrier"),o.ship?o.ship.trk:""]]; + } + return []; +} +function stageTitle(o,stId){const n=(o.sets.length||Math.max(1,Math.ceil(o.qty*1.08/1000)))*2; + return {mast:"Vinyl mastering, sides A and B",cut:"Cut the lacquers, sides A and B",plate:o.repress?`Grow ${n} stampers from the archived mothers`:`Electroforming: father, mother, ${n} stampers`,prep:`Finish and measure ${n} stampers`,tp:"Press 10 test pressings",run:`Press run, ${fI(o.qty)} copies`,fin:`Pack ${fI(o.good||o.qty)} copies`,ship:`Dispatch ${o.ship?o.ship.boxes:Math.ceil(o.qty/50)} cartons`}[stId];} +function stageWO(o,stId,status){ + const h=o.hist.filter(x=>x.id===stId).pop(); + const w={id:`WO-${o.n}-${stId}`,st:STG_WO[stId],title:stageTitle(o,stId),o,ref:o.id,refT:o.title,status,steps:stageSteps(o,stId),pr:0,start:null,end:null,plan:null,where:""}; + if(status==="done"){w.start=h.a;w.end=h.b;w.pr=1;} + else if(status==="doing"){w.start=o.ss; + if(stId==="run"){const p=pById(o.press),e=eta(o);w.end=e?e.runEnd:null;w.where=o.press||"waiting for a press"; + w.pr=p&&p.st==="change"&&o.good===0?Math.max(0.13,Math.min(0.62,0.13+0.5*(1-(p.until-S.t)/(40*MIN)))):o.good===0?0.63:0.75+0.24*o.good/o.qty;} + else{w.end=o.ss+o.sd;w.pr=Math.max(0,Math.min(1,(S.t-o.ss)/o.sd));}} + else{if(stId==="run"){const s=schedEst(o);if(s){w.plan=s.start;w.end=s.end;w.where=s.press;}}else{const cur=STAGES[o.si];if(cur.d&&!cur.gate)w.plan=o.ss+o.sd;}} + if(stId==="tp")w.where="D1-"+(o.n%2?"A":"B"); + const p=pById(o.press); + w.who=status==="todo"?"":stId==="run"&&status==="doing"&&p?p.op:opAt(w.id,status==="done"?w.end:S.t,status==="done"||STG_WO[stId]==="galv"?w.st:(inShift(S.t)?w.st:null)); + return w; +} +function orderWOs(o){return STAGES.map((s,i)=>[s,i]).filter(([s])=>STG_WO[s.id]&&stageOn(o,s)).map(([s,i])=>{const h=o.hist.filter(x=>x.id===s.id).pop();return stageWO(o,s.id,h&&h.b!=null?"done":o.si===i?"doing":"todo");});} +function workOrders(){ + const W=[],t0=S.day.t0,T=S.t; + for(const o of S.orders){ + for(const h of o.hist)if(h.b!=null&&h.b>=t0&&h.b<=T&&STG_WO[h.id])W.push(stageWO(o,h.id,"done")); + const cur=STAGES[o.si].id; + if(STG_WO[cur])W.push(stageWO(o,cur,"doing")); + if(cur==="queue")W.push(stageWO(o,"run","todo")); + if(["mast","cut","plate","prep","fin"].includes(cur)){const ni=nextIdx(o),ns=STAGES[ni];if(STG_WO[ns.id]&&!ns.gate)W.push(stageWO(o,ns.id,"todo"));} + for(const s of o.sets)if(s.retired&&s.retired>=t0&&s.retired+20*MIN<=T)W.push(setWO(o,s.n+1,"done",s.retired,s.retired+20*MIN,s.press)); + } + for(const p of S.presses){ + const o=p.order; + if(o&&p.st==="change"&&p.why.startsWith("Stamper change"))W.push(setWO(o,o.setIdx+1,"doing",p.until-20*MIN,p.until,p.id)); + else if(o&&p.set&&p.set.plays>p.set.life*0.75&&o.qty-o.good>p.set.life-p.set.plays)W.push(setWO(o,p.set.n+1,"todo",addShiftH(S.t,(p.set.life-p.set.plays)/rateH(p)),null,p.id)); + for(const s of p.stops){const w={id:`WO-${p.id}-m${Math.floor(s.t/1000).toString(36)}`,st:"maint",title:"Clear fault: "+s.why.toLowerCase(),p,ref:p.id,refT:s.order||"",steps:[["Fault acknowledged on the press panel","badge"],["Fix: "+s.why.toLowerCase()],["Test cycle, back to production"]],start:s.t,end:s.until,where:p.id}; + if(s.t<=T&&s.until>T)W.push(Object.assign(w,{status:"doing",pr:(T-s.t)/(s.until-s.t),who:opAt(w.id,T,"maint")})); + else if(s.until>=t0&&s.until<=T)W.push(Object.assign(w,{status:"done",pr:1,who:opAt(w.id,s.until,"maint")}));} + } + for(const L of S.lots){ + const lab={id:`WO-${L.id}-lab`,st:"lab",title:"Lab gate, "+MAT[L.mat].toLowerCase(),L,ref:L.id,refT:SUP[L.sup],steps:[["Scan the lot label, take a sealed sample",L.id],["Moisture"],["Heat stability strips"],["Density"],[L.mat==="black"?"Colour and gloss":"Colour"],["Fines and ash"],["Plaque: speck count"],["Certificate against the specification"]],start:L.arr,end:L.labDone}; + if(L.st==="lab")W.push(Object.assign(lab,{status:"doing",pr:Math.max(0,Math.min(1,(T-L.arr)/(L.labDone-L.arr))),who:opAt(lab.id,T,inShift(T)?"lab":null)})); + else if(L.labDone>=t0&&L.labDone<=T)W.push(Object.assign(lab,{status:"done",pr:1,who:opAt(lab.id,L.labDone,"lab")})); + const rel={id:`WO-${L.id}-rel`,st:"lab",title:"Release test pressing, "+L.id,L,ref:L.id,refT:MAT[L.mat],steps:[["Scan the lot into the test hopper",L.id],["Press release test pressings"],["Optical check and audio comparison"],["Release the lot in the system"]],start:L.labDone,end:L.tpDone||L.rel}; + if(L.st==="tp")W.push(Object.assign(rel,{status:"doing",pr:Math.max(0,Math.min(1,(T-L.labDone)/(L.tpDone-L.labDone))),who:opAt(rel.id,T,inShift(T)?"lab":null)})); + else if(L.rel&&L.rel>=t0&&L.rel<=T)W.push(Object.assign(rel,{status:"done",pr:1,who:opAt(rel.id,L.rel,"lab")})); + if(L.st==="hold"||L.st==="dry"){const d={id:`WO-${L.id}-dry`,st:"lab",title:"Re-dry and retest "+L.id,L,ref:L.id,refT:"moisture "+f2(L.res.moist)+" %, limit "+f2(LIMS.moist)+" %",steps:[["Move the lot to the dryer",L.id],["Dry for 4 h"],["Retest moisture, then the release test pressing"]],prio:true}; + if(L.st==="hold")W.push(Object.assign(d,{status:"todo",pr:0,plan:T,who:""})); + else W.push(Object.assign(d,{status:"doing",start:L.dryDone-4*HR,end:L.dryDone,pr:(T-(L.dryDone-4*HR))/(4*HR),who:opAt(d.id,T,inShift(T)?"lab":null)}));} + } + for(const x of S.aud){const o=S.byId[x.o],a=x.a;if(!o)continue; + const w={id:`WO-${o.n}-q${a.copy}s${a.set}`,st:"qc",title:"Listening check, "+(a.first?"first-off of set "+a.set:"copy #"+fI(a.copy)),o,ref:o.id,refT:o.title,steps:[["Scan the record key",a.key],["Needle-drop, side A"],["Needle-drop, side B"],["Compare with the source master"],["Result to the log"]],start:a.t,end:a.rt,where:"Audio QC station"}; + if(a.t<=T&&a.rt>T)W.push(Object.assign(w,{status:"doing",pr:(T-a.t)/(a.rt-a.t),who:opAt(w.id,T,inShift(T)?"qc":null)})); + else if(a.rt>=t0&&a.rt<=T)W.push(Object.assign(w,{status:"done",pr:1,who:opAt(w.id,a.rt,"qc")}));} + return W; +} +function setWO(o,k,status,start,end,pid){const old=o.sets[k-2],nw=o.sets[k-1]; + return {id:`WO-${o.n}-set${k}`,st:"press",title:`Stamper change to set ${k} on ${pid}`,o,ref:o.id,refT:o.title,status,pr:status==="done"?1:status==="doing"?Math.max(0,Math.min(1,(S.t-start)/(end-start))):0,start:status==="todo"?null:start,end,plan:status==="todo"?start:null,where:pid, + steps:[["Scan the worn set out",old?old.st.map(x=>x.id).join(", "):""],["Scan set "+k+" in",nw?nw.st.map(x=>x.id).join(", "):"from the rack"],["Mount and heat up"],["First-off to audio QC"]],who:status==="todo"?"":(pById(pid)&&pById(pid).op)||opAt(o.id+k,start,"press")};} +function woById(id){const W=workOrders().find(w=>w.id===id);if(W)return W; + const m=/^WO-(\d+)-(mast|cut|plate|prep|tp|run|fin|ship)$/.exec(id||"");if(!m)return null; + const o=S.byId["CNX-"+m[1]];if(!o)return null;return orderWOs(o).find(w=>w.id===id)||null;} +function woCur(w){const n=w.steps.length;return w.status==="done"?n:w.status==="todo"?0:Math.min(n-1,Math.floor(w.pr*n));} +function woTime(w){ + if(w.status==="done")return "done "+fDT(w.end)+(w.who?" · "+w.who:""); + if(w.status==="doing")return "started "+fDT(w.start)+(w.end&&isFinite(w.end)?", done about "+fDT(w.end):""); + if(w.prio&&w.plan)return "now, waiting for the lab"; + return w.plan?(w.st==="press"?"planned ":"arrives about ")+fDT(w.plan)+(w.where?" on "+w.where:""):"later"; +} +const stChip=k=>`${WSTN[k][1]}`; +function woCard(w){const c=woCur(w),n=w.steps.length; + return `What every station does now and next. On the floor each station shows its own list on a kiosk: the operator taps the access badge and scans what each step asks for, and every confirmation lands in the event log.
Nothing here.
'}${list.length>14?`+ ${list.length-14} more; pick a station above.
`:""}This work order is closed and no longer on the board.
',kG:""}; + const c=woCur(w),n=w.steps.length,span=w.end&&w.start&&isFinite(w.end)?w.end-w.start:0; + /* when each finished step was confirmed: spread over the whole job when done, over the time so far when in progress (a press run's setup steps fall in the 40 min changeover) */ + const run=/-run$/.test(w.id); + const stepAt=i=>run&&w.start?(i<5?w.start+(i+1)*8*MIN:i===5?w.start+41*MIN:w.end):w.status==="done"?(span?w.start+span*(i+1)/n:0):w.start?w.start+(S.t-w.start)*(i+1)/(c+0.5):0; + const steps=`${L.id} ${esc(MAT[L.mat])}
${esc(SUP[L.sup])}, ${fI(L.kg)} kg, received ${fDT(L.arr)} ${s[1]}
${w.p.id} ${w.p.kind==="duplex"?"Newbilt Duplex press":"Newbilt Classic"}
${esc(pstate(w.p))}
Writes to the log: ${esc(WO_LOG[w.st])}.
Steps follow the simulation in this demo. At the station each confirmation is an event in the hash-chained log, so the order history and the customer portal update as the work is done.