From f9b8407561df04b0f5cfde0f57da159e076fdf31 Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 16 Oct 2026 10:41:29 +0700 Subject: [PATCH] Factory console: work orders for the operators New "Work orders" section in the factory console (#work), derived from the same simulated records as the rest of the page, one work order per station and job: - order stages: mastering, lacquer cutting, electroforming, stamper prep, test pressings, press run, packing, dispatch - stamper changes (planned when a set nears its life, in progress during the change), fault clearing on a stopped press (maintenance) - material lab: lab gate per compound lot, release test pressing, and the held lot's re-dry and retest (button starts it) - audio QC: each sampled copy's listening check Board: In progress (press floor first) / Next up (planned press runs and stamper changes with time and press, jobs arriving at the next station) / Done today, filter per station, KPIs. Each work order opens a kiosk view: the steps an operator follows (badge in, scan what the step asks for, with the IDs to scan, confirm), confirmation times and who did it, the order, lot or press it belongs to, and what it writes to the log. Order detail lists the order's work orders by station. Mastering and cutting are booked to the engineers, the lab, QC and maintenance to technicians, the rest to numbered operators; jobs finishing at night are booked to the late shift. README, digital-factory.md demo paragraph and the landing page mention the work orders. --- README.md | 3 +- docs/digital-factory.md | 5 +- docs/factory-console.html | 187 +++++++++++++++++++++++++++++++++++++- site/public/index.html | 3 +- 4 files changed, 189 insertions(+), 9 deletions(-) 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)=>`

${l}${v}${s||" "}
`; /* ================= factory pages ================= */ -const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]]; -function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage; +const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]]; +function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage==="wo"?"work":S.ui.fpage; const bd={floor:S.presses.filter(p=>p.st==="stop"||p.st==="nopvc").length,orders:cnt(o=>o.si!==SI.done),lab:S.lots.filter(L=>L.st==="hold").length,quality:S.aud.filter(x=>x.a.rev&&x.a.rt<=S.t&&S.t-x.a.rt<6*HR).length}; return NAV.map(([k,ic,l])=>{const b=bd[k];const bad=(k==="lab"||k==="floor"||k==="quality")&&b>0;return `${ic}${l}${b?`${fI(b)}`:""}`;}).join("");} function pressCard(p){ @@ -823,7 +864,7 @@ PAGES.floor={

Optical inspection, every record

record key: stamper · press · cycle time

Needs attention

-

Stations

+

Stations

Work orders by station →

Orders by stage

All orders

Good records per day

shifts 06:00 to 22:00
`, blocks(){ @@ -891,6 +932,7 @@ PAGES.order={

Compound lot

lab gate
${b.comp}

Packing and shipping

${b.pack}

Audio comparison, sampled

first-off per stamper set, about every 50th record, every 25th near end of life
${audioTable(o.audio,false)}
+

Work orders

one per station

Events for this order

from the hash-chained log
${ledgerTable(o.ev.slice(-14).reverse(),false)}
` }; } @@ -952,6 +994,140 @@ PAGES.ledger={ blocks:()=>({gL:ledgerTable(S.led.slice(-60).reverse(),true)+`

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 `
${w.id}${stChip(w.st)}${esc(w.who||"")}
${esc(w.title)}
${esc(w.ref)}${w.refT?" · "+esc(w.refT):""}
${w.status==="doing"?bar(w.pr,w.st==="press"?"acc":w.st==="maint"?"warn":"")+`Step ${c+1} of ${n}: ${esc(w.steps[c][0])}`:""}${esc(woTime(w))}
`;} +PAGES.work={ + layout:()=>`
Factory console

Work orders

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.

`, + blocks(){ + const all=workOrders(),f=S.ui.wst||"all",W=all.filter(w=>f==="all"||w.st===f); + const col=(k,l,list)=>`

${l}${list.length}

${list.slice(0,14).map(woCard).join("")||'

Nothing here.

'}${list.length>14?`

+ ${list.length-14} more; pick a station above.

`:""}
`; + const SO={press:0,maint:1,cut:2,galv:3,prep:4,lab:5,pack:6,ship:7,mast:8,qc:9}; + const doing=W.filter(w=>w.status==="doing").sort((a,b)=>SO[a.st]-SO[b.st]||(a.end||9e15)-(b.end||9e15)),todo=W.filter(w=>w.status==="todo").sort((a,b)=>(b.prio?1:0)-(a.prio?1:0)||(a.plan||9e15)-(b.plan||9e15)),done=W.filter(w=>w.status==="done").sort((a,b)=>b.end-a.end); + const busy=new Set(all.filter(w=>w.status==="doing").map(w=>w.st)).size; + return { + wK:kpi("In progress",fI(all.filter(w=>w.status==="doing").length),"across "+busy+" of "+WST.length+" stations")+kpi("Next up",fI(all.filter(w=>w.status==="todo").length),"planned or arriving")+kpi("Done today",fI(all.filter(w=>w.status==="done").length),"closed since 00:00")+kpi("Blocked by customers",fI(cnt(o=>STAGES[o.si].gate)),"orders at an approval gate"), + wF:[["all","All stations"],...WST].map(([k,l])=>{const n=all.filter(w=>(k==="all"||w.st===k)&&w.status!=="done").length;return ``;}).join(""), + wB:col("doing","In progress",doing)+col("todo","Next up",todo)+col("done","Done today",done) + }; + } +}; +PAGES.wo={ + layout:()=>`← Work orders
`, + blocks(){ + const w=woById(S.ui.arg); + if(!w)return {kH:'

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=`
    ${w.steps.map(([t,scan],i)=>{const st=i${st==="done"?"✓":i+1}
    ${esc(t)}${st==="done"?`${at}${w.who?" · "+esc(w.who):""}`:st==="now"?`in progress${w.st==="press"&&w.o&&w.o.press&&i===6?": "+fI(w.o.good)+" of "+fI(w.o.qty)+" good":""}`:""}
    ${scan?`${esc(scan)}`:""}`;}).join("")}
`; + let ref=""; + if(w.o){const o=w.o;ref=`
${discSVG(o,64,false)}
${esc(o.title)}
${esc(o.artist)}
${esc(fmtS(o))} · ${fI(o.qty)} copies
`;} + else if(w.L){const L=w.L,s=lotStatus(L);ref=`

${L.id} ${esc(MAT[L.mat])}
${esc(SUP[L.sup])}, ${fI(L.kg)} kg, received ${fDT(L.arr)} ${s[1]}

`;} + else if(w.p){ref=`

${w.p.id} ${w.p.kind==="duplex"?"Newbilt Duplex press":"Newbilt Classic"}
${esc(pstate(w.p))}

`;} + const link=w.o?`Order ${w.o.id}`:w.L?`Lab`:w.p?`Press ${w.p.id}`:""; + const act=w.L&&w.L.st==="hold"&&w.status==="todo"?``:""; + return { + kH:`
${w.id}

${esc(w.title)}

${stChip(w.st)}${w.status==="doing"?"In progress":w.status==="done"?"Done":"Next up"}${esc(woTime(w))}
${act}${link}
`, + kG:`

${w.o?"Order":w.L?"Compound lot":"Press"}

${esc(w.ref)}
${ref}
Station
${WSTN[w.st][1]}${w.where?" · "+esc(w.where):""}
Assigned
${esc(w.who||"next free operator")}
${w.start?`
Started
${fDT(w.start)}
`:""}${w.plan&&w.status==="todo"&&!w.prio?`
Planned
${fDT(w.plan)}
`:""}${w.end&&isFinite(w.end)?`
${w.status==="done"?"Done":"Done about"}
${fDT(w.end)}
`:""}
${w.status==="doing"?`
${bar(w.pr,"acc")}
`:""}

Writes to the log: ${esc(WO_LOG[w.st])}.

+

Steps

kiosk view: badge in, scan, confirm
${steps}

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.

` + }; + } +}; + /* ================= customer portal ================= */ function cStatus(o){ const st=STAGES[o.si].id,p=pById(o.press),e=eta(o); @@ -1056,6 +1232,7 @@ const ACT={ view(v){location.hash=v==="customer"?"customer="+(S.ui.cust||DEMO.press):(S.ui.fhash||"floor");}, speed(v){S.speed=+v;S.run=true;topRender();}, play(){S.run=!S.run;topRender();}, + wst(v){S.ui.wst=v;render();}, filter(v){S.ui.filter=v;if(S.ui.fpage!=="orders")location.hash="orders";else render();}, approve(id){const o=S.byId[id];const g=o&&STAGES[o.si].gate;if(!g)return;approve(o,g,S.t,"Customer portal");if(g===3)o.prio=1;enter(o,nextIdx(o),S.t); toast(g===3?"Approved. Your pressing slot is booked; the start time is on the order now.":"Approved. The next step starts now.");render();}, diff --git a/site/public/index.html b/site/public/index.html index 8f320da..09c9ba0 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -118,7 +118,8 @@ document.documentElement.setAttribute("data-theme",t);})(); Factory console and order status (investor demo) The factory application on a simulated plant day: a Newbilt - Duplex system and a Classic with live counters, every record through optical + Duplex system and a Classic with live counters, work orders for + every station, every record through optical inspection, audio samples, the material lab gate, stamper metrology and the hash-chained event log. Switch to the customer portal to follow the same orders: steps and approvals, copies