diff --git a/docs/digital-factory.md b/docs/digital-factory.md
index 2d111d3..d6df96a 100644
--- a/docs/digital-factory.md
+++ b/docs/digital-factory.md
@@ -521,6 +521,10 @@ 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,
+the ERPNext link (every posting the floor makes: sales orders in by
+webhook, job cards, batches and quality inspections, stock entries,
+downtime entries, delivery notes and invoices, through the outbox, with
+the ERPNext documents on each order and work order),
and the customer portal of the same orders (progress through the three
gates, copies pressed so far, the lab certificate of the compound lot,
listening checks). All orders, values and IDs are simulated; limits are
diff --git a/docs/factory-console.html b/docs/factory-console.html
index ac5e291..3af2429 100644
--- a/docs/factory-console.html
+++ b/docs/factory-console.html
@@ -155,6 +155,15 @@ document.documentElement.setAttribute("data-theme",t);})();
.fr.hd > span { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; }
.feed.cf .fr { grid-template-columns: 8.4ch 7ch 10.6ch 5ch minmax(0, 1fr); }
.fr.bad .v { color: var(--bad); font-weight: 650; }
+ /* ERPNext postings: fixed ch columns, IDs never shift the row */
+ .er { display: grid; grid-template-columns: 8.4ch 18ch 19ch minmax(0, 1fr) 10ch; grid-template-areas: "t d n e s"; column-gap: 14px; padding: 4px 0; border-bottom: 1px dashed var(--line); align-items: baseline; font-family: var(--mono); font-size: 12.5px; font-variant-numeric: tabular-nums; }
+ .er:last-child { border-bottom: 0; }
+ .er > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .er .t { grid-area: t; } .er .d { grid-area: d; color: var(--mute); } .er .n { grid-area: n; } .er .e { grid-area: e; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } .er .s { grid-area: s; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ok); }
+ .er .s.q { color: var(--warn); } .er .s.in { color: var(--mute); }
+ .er.hd { color: var(--faint); border-bottom: 1px solid var(--line); padding-top: 0; }
+ .er.hd > span { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--faint); }
+ .own td:first-child { font-weight: 650; white-space: nowrap; }
.al { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); color: var(--ink); text-decoration: none; }
.al:last-child { border-bottom: 0; }
@@ -260,8 +269,9 @@ document.documentElement.setAttribute("data-theme",t);})();
.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 { display: grid; grid-template-columns: 112px 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 .mono { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.wol a:hover b { text-decoration: underline; }
.wol b { font-weight: 560; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -358,6 +368,9 @@ document.documentElement.setAttribute("data-theme",t);})();
.fr .fo { display: none; }
.feed.cf .fr { grid-template-columns: 8.4ch 6.4ch 10.6ch minmax(0, 1fr); }
.feed.cf .fr .pr { display: none; }
+ .er { grid-template-columns: 8.4ch minmax(0, 1fr) 9ch; grid-template-areas: "t n s" ". e e"; column-gap: 9px; }
+ .er .d, .er.hd .e { display: none; }
+ .er .e { white-space: normal; color: var(--mute); font-size: 12px; }
.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); }
@@ -391,7 +404,7 @@ document.documentElement.setAttribute("data-theme",t);})();
- What this is. The factory application from the digital factory plan (working name cnx-factory): every machine, instrument and operator check writes to one append-only log, and the customer portal is a scoped view of the same records.Simulated: all orders, values and IDs. From the plan: stages and the three artist gates, the inspection layers, Newbilt cycle times, two shifts 06:00 to 22:00. The demo runs one Duplex system and one Classic. Limits are illustrative: the acceptance specifications are still to be written.
+ What this is. The factory application from the digital factory plan (working name cnx-factory): every machine, instrument and operator check writes to one append-only log, and the customer portal is a scoped view of the same records.Simulated: all orders, values and IDs. From the plan: stages and the three artist gates, the inspection layers, Newbilt cycle times, two shifts 06:00 to 22:00. The demo runs one Duplex system and one Classic. Limits are illustrative: the acceptance specifications are still to be written.ERPNext runs the whole plant: orders, job cards, batches and invoices are posted there (see ERPNext).
@@ -470,7 +483,7 @@ const PST={run:"Running",warm:"Warm-up",change:"Changeover",stop:"Stopped",off:"
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},
+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,erp:[],erpN:0,eseq:{},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,stops:[]};}
/* demo fleet for now: one Duplex system (two presses on one extruder set) and one Classic */
@@ -481,9 +494,51 @@ const pById=id=>S.presses.find(p=>p.id===id);
function log(t,k,txt,actor,oid){
S.ledN++;const prev=S.led.length?S.led[S.led.length-1].h:"0000000000000000";
const e={t,k,txt,actor,oid,p:prev};e.h=hx(prev+t+k+txt);S.led.push(e);
+ erpFrom(t,k,txt,oid);
if(oid&&S.byId[oid]){const o=S.byId[oid];o.ev.push(e);if(o.ev.length>60)o.ev.shift();}
if(!S.warm&&S.led.length>900)S.led.splice(0,S.led.length-800);
}
+/* ================= ERPNext link: business documents go to ERPNext through an outbox (names simulated, ERPNext default naming series) ================= */
+const p5=n=>String(n).padStart(5,"0"),yr=t=>new Date(t).getFullYear();
+const jcName=key=>"PO-JOB"+p5(10000+fnv(key,2166136261)%89990);
+const erpO=(o,k)=>({so:"SAL-ORD-",wo:"MFG-WO-",dn:"MAT-DN-",si:"ACC-SINV-"})[k]+yr(o.created)+"-"+p5(o.n);
+const eNum=(k,base)=>(S.eseq[k]=(S.eseq[k]||base)+1);
+let STF=null;
+function ePost(t,dir,dt,name,txt,oid){S.erpN++;S.erp.push({t,dir,dt,name,txt,oid});if(S.erp.length>900)S.erp.splice(0,S.erp.length-600);}
+/* when a posting left the outbox: within a minute, or after the nightly ERPNext backup window 01:00 to 01:15 (hash-based, never the simulation's random stream) */
+function eSent(e){if(e.dir==="in")return e.t;const d=new Date(e.t),lag=(15+fnv(e.name+e.t,2166136261)%45)*SEC;
+ if(d.getHours()===1&&d.getMinutes()<15){d.setMinutes(15,0,0);return d.getTime()+lag;}return e.t+lag;}
+function erpFrom(t,k,txt,oid){
+ if(!STF)STF=Object.fromEntries(STAGES.map(s=>[s.f,s.id]));
+ const o=oid&&S.byId[oid],Y=yr(t);let m;
+ if(o){
+ if(k==="order"&&/ received: /.test(txt)){ePost(t,"in","Sales Order",erpO(o,"so"),`submitted by the order platform: ${fI(o.qty)} × ${fmtS(o)}`,o.id);
+ ePost(t+SEC,"out","Work Order",erpO(o,"wo"),`created from ${erpO(o,"so")}${o.repress?", repress routing, no cut":""}`,o.id);return;}
+ if(k==="order"&&(m=/ → (.+)$/.exec(txt))){const nid=STF[m[1]],h=o.hist,prev=h.length>1?h[h.length-2].id:null;
+ if(prev&&STG_WO[prev]&&prev!=="run")ePost(t,"out","Job Card",jcName(`WO-${o.n}-${prev}`),`completed: ${STAGES[SI[prev]].f}, time logs from the kiosk`,o.id);
+ if(prev==="plate"&&o.mo&&!o.repress)ePost(t,"out","Serial No",o.mo[0].id,`fathers and mothers ${o.fa.map(x=>x.id).join(", ")}, ${o.mo.map(x=>x.id).join(", ")} into warehouse Archive`,o.id);
+ if(nid==="ship"){ePost(t,"out","Delivery Note",erpO(o,"dn"),`${fI(o.good||o.qty)} records, against ${erpO(o,"so")}`,o.id);ePost(t+2*SEC,"out","Sales Invoice",erpO(o,"si"),`from ${erpO(o,"dn")}`,o.id);}
+ return;}
+ if(k==="metal"&&/: lacquers /.test(txt)){ePost(t,"out","Serial No",o.lac[0].id,`lacquers ${o.lac.map(x=>x.id).join(", ")} registered, warehouse Cutting room`,o.id);return;}
+ if(k==="press"&&(m=/^(\S+) set up for /.exec(txt))){ePost(t,"out","Job Card",jcName(`WO-${o.n}-run`),`started on ${m[1]}, stamper set ${o.setIdx+1}`,o.id);return;}
+ if(k==="press"&&(m=/^(\S+) finished /.exec(txt))){ePost(t,"out","Job Card",jcName(`WO-${o.n}-run`),`completed on ${m[1]}: ${fI(o.good)} good, ${fI(o.rej)} rejected`,o.id);
+ ePost(t+SEC,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Manufacture: ${fI(o.good)} into Finished goods, rejects to regrind`,o.id);return;}
+ if(k==="press"&&(m=/^(\S+) stopped: (.+)$/.exec(txt))){ePost(t,"out","Downtime Entry","DT-"+Y+"-"+p5(eNum("dt",200)),`${m[1]}: ${m[2]}`,o.id);return;}
+ return;}
+ if(k==="lab"&&(m=/^(PVC-\d+) (.*)$/.exec(txt))){const id=m[1],r=m[2],L=S.lots.find(x=>x.id===id);
+ if(/^received/.test(r))ePost(t,"out","Purchase Receipt","MAT-PRE-"+Y+"-"+p5(eNum("pre",400)),`batch ${id}${L?", "+fI(L.kg)+" kg":""}, warehouse Quarantine`,null);
+ else if(/^on hold/.test(r)||/^passed the lab/.test(r)){const qi="MAT-QA-"+Y+"-"+p5(eNum("qa",500));if(L)L.qi=qi;ePost(t,"out","Quality Inspection",qi,/^on hold/.test(r)?`Rejected, batch ${id} held: ${r.replace(/^on hold: /,"")}`:`Accepted, batch ${id}: incoming lab checks`,null);}
+ else if(/^released/.test(r))ePost(t,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Material Transfer: batch ${id} Quarantine to Stores, released by test pressing`,null);
+ else if(/^into the/.test(r))ePost(t,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Material Transfer for Manufacture: batch ${id} to the ${/black/.test(r)?"black":"colour"} hopper`,null);
+ }
+}
+function erpWO(w){
+ if(w.o)return `Job Card ${jcName(w.id)} of ${erpO(w.o,"wo")} `;
+ if(w.L)return w.L.qi?`Quality Inspection ${w.L.qi} , batch ${w.L.id} `:`Batch ${w.L.id} ; the Quality Inspection is posted with the result`;
+ if(w.p)return `Asset Maintenance Log ACC-AML-${yr(w.start||S.t)}-${p5(1000+fnv(w.id,2166136261)%8999)} `;
+ return "-";}
+function erpCard(o){const wos=orderWOs(o),dn=wos.filter(w=>w.status==="done").length;
+ return `Customer ${esc(o.artist)} Sales order ${erpO(o,"so")} , submitted ${fDT(o.created)}Work order ${erpO(o,"wo")} , ${fI(Math.min(o.good,o.qty))} of ${fI(o.qty)} producedJob cards ${dn} of ${wos.length} completed Batches ${o.lots.length?o.lots.join(", "):"-"} Delivery ${o.si>=SI.ship?`${erpO(o,"dn")} , invoice ${erpO(o,"si")} `:'note and invoice when it ships '} ERPNext link : what this order posted. Document names are simulated.
`;}
function rechain(){S.led.sort((a,b)=>a.t-b.t);if(S.led.length>800)S.led=S.led.slice(-800);let prev=hx("genesis "+(S.ledN-S.led.length));for(const e of S.led){e.p=prev;e.h=hx(prev+e.t+e.k+e.txt);prev=e.h;}}
/* ================= orders ================= */
@@ -700,7 +755,7 @@ function backfill(o,untilId,tUntil,fix){
const seq=[];for(let i=0;i<=SI[untilId];i++)if(stageOn(o,STAGES[i]))seq.push(i);
const ds=seq.slice(0,-1).map(i=>{const s=STAGES[i];if(fix[s.id]!=null)return fix[s.id]*DAY;const d=s.d||[0.5,1.5];return U(d[0],d[1])*DAY*(s.gate?0.45:1);});
let t=tUntil-ds.reduce((a,b)=>a+b,0);
- o.hist=[];o.ev=[];o.created=t;o.ss=t;
+ o.hist=[];o.ev=[];o.created=t;o.ss=t;S.erp=S.erp.filter(e=>e.oid!==o.id);
log(t,"order",`${o.id} received: ${o.title}, ${fI(o.qty)} × ${fmtS(o)}`,"Customer portal",o.id);
seq.forEach((i,k)=>{if(k>0&&STAGES[seq[k-1]].gate)approve(o,STAGES[seq[k-1]].gate,t,"Customer portal");enter(o,i,t);if(kThe presses ar
const kpi=(l,v,s,c)=>`${l} ${v} ${s||" "}
`;
/* ================= factory pages ================= */
-const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]];
+const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"],["erp","⇄","ERPNext"]];
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("");}
@@ -941,6 +996,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)}
+
ERPNext business documents of this order ${erpCard(o)}
Work orders one per station
Events for this order from the hash-chained log ${ledgerTable(o.ev.slice(-14).reverse(),false)} `
};
@@ -1003,6 +1059,27 @@ 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.
`})
};
+const OWN=[["Orders","Sales Order, Delivery Note, Sales Invoice","the three artist gates, the portal view"],["Production","Work Order, a Job Card per station, Downtime Entry","cycles, telemetry, kiosk scans, stamper changes"],["Materials","Batch, Purchase Receipt, Quality Inspection, Stock Entry","instrument files, CoA scans, which lot fed which cycle"],["Metal parts","Serial No in a warehouse: archive, rack, press","genealogy, metrology, play counts, sets per mother"],["Quality","inspection results per batch and run","every record's optical result, audio samples"],["Plant","Asset, maintenance schedule, accounts, payroll","run hours, alarms, the hash-chained log"]];
+PAGES.erp={
+ layout:()=>`Factory console
ERPNext link The whole plant runs ERPNext. It owns orders, stock, inspections, deliveries and money; this application owns the floor and posts what happens there in business-sized pieces, through an outbox, so no press waits for the ERP.
+
+
Postings and webhooks latest 40, newest first
+
+
Today by document type since 00:00
`,
+ blocks(){
+ const T=S.t,d0=S.day.t0,L=S.erp.filter(e=>e.t<=T).sort((a,b)=>b.t-a.t),today=L.filter(e=>e.t>=d0);
+ const q=L.filter(e=>e.dir==="out"&&eSent(e)>T),inT=today.filter(e=>e.dir==="in").length;
+ const rc=new Date(T);rc.setHours(2,0,0,0);if(rc.getTime()>T)rc.setDate(rc.getDate()-1);
+ const by={};for(const e of today)by[e.dt]=(by[e.dt]||0)+1;const BL=Object.entries(by).sort((a,b)=>b[1]-a[1]),mx=BL.length?BL[0][1]:1;
+ const row=e=>{const qd=e.dir==="out"&&eSent(e)>T;return `${fTs(e.t)} ${esc(e.dt)} ${esc(e.name)} ${e.oid?`${e.oid} `:""}${esc(e.txt)} ${qd?"… outbox":e.dir==="in"?"← webhook":"✓ posted"} `;};
+ return {
+ eK:kpi("Posted today",fI(today.filter(e=>e.dir==="out").length),"documents created or updated")+kpi("In the outbox",fI(q.length),q.length?"sent within a minute":"held only in the nightly backup, 01:00 to 01:15",q.length>40?"warn":"")+kpi("Webhooks in today",fI(inT),"sales orders from the order platform")+kpi("Reconciled",fT(rc.getTime()),"work order and batch totals match","ok"),
+ eL:L.length?`Time Document type Document Event Status
${L.slice(0,40).map(row).join("")}
`:'Nothing posted yet.
',
+ eO:`ERPNext This application ${OWN.map(([a,b,c])=>`${a} ${b} ${c} `).join("")}
`,
+ eD:(BL.length?`${BL.map(([r,n])=>`
${esc(r)} ${bar(n/mx,"acc")}${fI(n)}
`).join("")}
`:'Nothing posted today yet.
')+`Postings are per order, run, lot, stop and shipment, never per record or cycle. Document names follow ERPNext's default naming series and are simulated; the real link is the Frappe REST API with an integration user per tool, and a nightly reconciliation of totals. ${fI(S.erpN)} postings since the simulation started.
`
+ };
+ }
+};
/* ================= 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]));
@@ -1131,7 +1208,7 @@ PAGES.wo={
const act=w.L&&w.L.st==="hold"&&w.status==="todo"?`Start re-drying `:"";
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])}.
+ 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")} ERPNext ${erpWO(w)} ${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.
`
};
}
@@ -1235,6 +1312,8 @@ function route(){
else{S.ui.view="factory";S.ui.fpage=PAGES[k]?k:"floor";S.ui.arg=v;S.ui.fhash=h||"floor";}
if(prev!==S.ui.view+S.ui.fpage+S.ui.arg+S.ui.cust)window.scrollTo(0,0);
layout();
+ /* phones: the nav is a horizontal strip; bring the current page's tab into view */
+ requestAnimationFrame(()=>{const n=$("#fnav"),a=n&&n.querySelector("a.on");if(a&&n.scrollWidth>n.clientWidth)n.scrollLeft=a.offsetLeft-(n.clientWidth-a.offsetWidth)/2;});
}
function toast(msg){const t=$("#toast");t.textContent=msg;t.hidden=false;clearTimeout(toast.h);toast.h=setTimeout(()=>{t.hidden=true;},4200);}
const ACT={