ERPNext is the only system of record; we build tools on it
Deploy test site / deploy (push) Successful in 46s

User (October 2026): we only build tools and interfaces that interact
with ERPNext; invoices, stock and the rest are kept there, except
telemetry and other machine data.

digital-factory.md: standing decision, decisions 2, 3, 6, 7 and 10
rewritten (ERPNext holds every record of the business and of production,
including artist gates, metal-part genealogy as serial numbers with
parents, and quality inspections; the machine-data store keeps only
cycle and record rows, the hash chain and time series, keyed to ERPNext
names; ERPNext first, then custom fields or document types in cnx_erp,
then a tool, never a second business database). Systems table,
architecture labels, factory application section (modules as a table:
records in ERPNext, what we build, machine data outside; our only tables
are the machine-data ones), ownership table, integration rules 4 and 8,
phasing and estimates (cnx_erp app 2 to 3 person-months; total 3.5 to 6).
artist-platform.md: campaigns are an ERPNext document from launch, pledges
post as advance payment entries; traceability, README, facility security
(archive location on the serial number), production timeline (sets per
mother on the mother's serial number), crane-workshop spares store,
total-investment.

Factory console: KPI row no longer touches the postings card (user
screenshot); ERPNext page, order card, sidenote and ledger say every
record lives in ERPNext and only machine data stays outside; new
postings for artist gates, stamper metrology inspections, stamper set
serial numbers (grown, retired with play count). Checked headless:
Edge dark and light, 1440/1024/390, no overflow or page errors, rows
aligned, pw_fc, pw_wo, pw_feed and pw_erp pass.
This commit is contained in:
grabowski committed 2026-10-16 12:14:52 +07:00
1 parent a101c9f7db
commit 5c303dfc61
9 files changed
+188 -158

No files matched your search

+14 -9
View File
@@ -164,6 +164,7 @@ document.documentElement.setAttribute("data-theme",t);})();
.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; }
.kpis + .card { margin-top: 16px; }
.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; }
@@ -404,7 +405,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="fac" id="factory">
<aside class="side">
<nav id="fnav" aria-label="Factory console"></nav>
<div class="sidenote"><b>What this is.</b> 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.<br><br><b>Simulated:</b> all orders, values and IDs. <b>From the plan:</b> 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.<br><br><b>ERPNext</b> runs the whole plant: orders, job cards, batches and invoices are posted there (see ERPNext).</div>
<div class="sidenote"><b>What this is.</b> The factory application from the digital factory plan (working name cnx-factory): tools and interfaces on ERPNext, which keeps every order, job card, batch, inspection, metal part and invoice. Machine data (cycles, per-record inspection, telemetry, audio) is the only thing kept outside it. The customer portal is a scoped view of the same records.<br><br><b>Simulated:</b> all orders, values and IDs. <b>From the plan:</b> 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.</div>
</aside>
<main id="fmain"></main>
</div>
@@ -494,7 +495,7 @@ 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);
erpFrom(t,k,txt,oid,actor);
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);
}
@@ -504,11 +505,11 @@ 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);}
function ePost(t,dir,dt,name,txt,oid){if(!S.erp)return;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){
function erpFrom(t,k,txt,oid,actor){
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){
@@ -519,6 +520,10 @@ function erpFrom(t,k,txt,oid){
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==="gate"&&(m=/ gate (\d) approved: (.+)$/.exec(txt))){ePost(t,"out","Artist Gate",`GATE-${yr(o.created)}-${p5(o.n)}-${m[1]}`,`submitted: ${m[2]} approved${actor?" by "+actor:""}`,o.id);return;}
if(k==="metal"&&(m=/: (\d+) stampers punched/.exec(txt))){ePost(t,"out","Quality Inspection","MAT-QA-"+Y+"-"+p5(eNum("qa",500)),`Accepted, ${m[1]} stampers: thickness, run-out, ridges; serial numbers to the rack`,o.id);return;}
if(k==="metal"&&(m=/extra stamper set (\d+) grown/.exec(txt))){const st=o.sets[+m[1]-1];ePost(t,"out","Serial No",st?st.st[0].id:"S-"+o.n,`stamper set ${m[1]} grown from mother ${o.mo?o.mo[0].id:""}, ${o.moUse||1} of about 10 sets`,o.id);return;}
if(k==="press"&&(m=/: stamper set (\d+) retired after ([\d,]+) plays/.exec(txt))){const st=o.sets[+m[1]-1];ePost(t,"out","Serial No",st?st.st[0].id:"S-"+o.n,`set ${m[1]} retired after ${m[2]} plays, play count closed`,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);
@@ -538,7 +543,7 @@ function erpWO(w){
if(w.p)return `Asset Maintenance Log <span class="mono">ACC-AML-${yr(w.start||S.t)}-${p5(1000+fnv(w.id,2166136261)%8999)}</span>`;
return "-";}
function erpCard(o){const wos=orderWOs(o),dn=wos.filter(w=>w.status==="done").length;
return `<dl class="kv"><dt>Customer</dt><dd>${esc(o.artist)}</dd><dt>Sales order</dt><dd><span class="mono">${erpO(o,"so")}</span>, submitted ${fDT(o.created)}</dd><dt>Work order</dt><dd><span class="mono">${erpO(o,"wo")}</span>, ${fI(Math.min(o.good,o.qty))} of ${fI(o.qty)} produced</dd><dt>Job cards</dt><dd>${dn} of ${wos.length} completed</dd><dt>Batches</dt><dd class="mono">${o.lots.length?o.lots.join(", "):"-"}</dd><dt>Delivery</dt><dd>${o.si>=SI.ship?`<span class="mono">${erpO(o,"dn")}</span>, invoice <span class="mono">${erpO(o,"si")}</span>`:'<span class="faint">note and invoice when it ships</span>'}</dd></dl><p class="note"><a href="#erp">ERPNext link</a>: what this order posted. Document names are simulated.</p>`;}
return `<dl class="kv"><dt>Customer</dt><dd>${esc(o.artist)}</dd><dt>Sales order</dt><dd><span class="mono">${erpO(o,"so")}</span>, submitted ${fDT(o.created)}</dd><dt>Work order</dt><dd><span class="mono">${erpO(o,"wo")}</span>, ${fI(Math.min(o.good,o.qty))} of ${fI(o.qty)} produced</dd><dt>Artist gates</dt><dd>${Object.keys(o.gates).length?Object.keys(o.gates).map(g=>`<span class="mono">GATE-${yr(o.created)}-${p5(o.n)}-${g}</span>`).join(", "):(o.repress?"none, repress":'<span class="faint">submitted on approval</span>')}</dd><dt>Job cards</dt><dd>${dn} of ${wos.length} completed</dd><dt>Metal parts</dt><dd>${o.mo?`Serial No <span class="mono">${o.mo.map(x=>x.id).join(", ")}</span> in Archive, ${o.moUse||1} of about 10 sets`:'<span class="faint">after plating</span>'}</dd><dt>Batches</dt><dd class="mono">${o.lots.length?o.lots.join(", "):"-"}</dd><dt>Delivery</dt><dd>${o.si>=SI.ship?`<span class="mono">${erpO(o,"dn")}</span>, invoice <span class="mono">${erpO(o,"si")}</span>`:'<span class="faint">note and invoice when it ships</span>'}</dd></dl><p class="note"><a href="#erp">ERPNext link</a>: every record of this order lives in ERPNext; cycles, per-record results and telemetry stay in the machine-data store. Document names are simulated.</p>`;}
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 ================= */
@@ -1055,13 +1060,13 @@ PAGES.metal={
};
function ledgerTable(L,showOrder){return `<div class="tw"><table class="t"><tr><th>Time</th><th>Actor</th><th>Event</th><th>Hash</th></tr>${L.map(e=>`<tr><td class="mono" style="white-space:nowrap">${fDT(e.t)}</td><td class="mute" style="white-space:nowrap">${esc(e.actor)}</td><td>${esc(e.txt).replace(/CNX-\d+/,m=>`<a href="#order=${m}">${m}</a>`)}</td><td class="mono mute" title="previous ${e.p}">${e.h.slice(0,10)}</td></tr>`).join("")}</table></div>`;}
PAGES.ledger={
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Event log</h1></div><p class="mute">Append-only; each entry carries the hash of the one before, so an edit anywhere breaks the chain</p></div><section class="card"><div id="gL"></div></section>`,
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Event log</h1></div><p class="mute">Machine events, append-only; each entry carries the hash of the one before, so an edit anywhere breaks the chain. Business records are submitted ERPNext documents</p></div><section class="card"><div id="gL"></div></section>`,
blocks:()=>({gL:ledgerTable(S.led.slice(-60).reverse(),true)+`<p class="note">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.</p>`})
};
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"]];
const OWN=[["Orders","Sales Order, Artist Gate, Delivery Note, Sales Invoice","none"],["Production","Work Order, a Job Card per station with its scans and times, Downtime Entry","every cycle, PLC telemetry"],["Materials","Batch, Purchase Receipt, Quality Inspection, Stock Entry","instrument raw files, which lot fed which cycle"],["Metal parts","Serial No with its parents, archive location, play count, sets per mother","metrology raw files"],["Quality","Quality Inspection per batch, run and flagged sample","every record's optical result, audio captures"],["Plant","Asset, maintenance, accounts, payroll","run hours, energy, environment, the hash-chained log"]];
PAGES.erp={
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>ERPNext link</h1></div><p class="mute" style="max-width:600px">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.</p></div>
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>ERPNext link</h1></div><p class="mute" style="max-width:600px">The whole plant runs ERPNext, and every record lives there: orders, gates, job cards, stock, inspections, metal parts, deliveries, invoices. This console is one of the interfaces on it. Only machine data stays outside: cycles, per-record results, telemetry, audio. Writes from the floor go through an outbox, so no press waits for the ERP.</p></div>
<div class="kpis" id="eK"></div>
<section class="card"><div class="ch"><h3>Postings and webhooks</h3><span class="mute">latest 40, newest first</span></div><div id="eL"></div></section>
<div class="cols2"><section class="card"><div class="ch"><h3>Who owns what</h3></div><div class="tw" id="eO"></div></section>
@@ -1075,7 +1080,7 @@ PAGES.erp={
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?`<div class="feed"><div class="er hd"><span class="t">Time</span><span class="d">Document type</span><span class="n">Document</span><span class="e">Event</span><span class="s">Status</span></div>${L.slice(0,40).map(row).join("")}</div>`:'<p class="mute">Nothing posted yet.</p>',
eO:`<table class="t own"><tr><th></th><th>ERPNext</th><th>This application</th></tr>${OWN.map(([a,b,c])=>`<tr><td>${a}</td><td>${b}</td><td class="mute">${c}</td></tr>`).join("")}</table>`,
eO:`<table class="t own"><tr><th></th><th>In ERPNext</th><th>Machine data, outside</th></tr>${OWN.map(([a,b,c])=>`<tr><td>${a}</td><td>${b}</td><td class="mute">${c}</td></tr>`).join("")}</table>`,
eD:(BL.length?`<div class="pareto">${BL.map(([r,n])=>`<div><span>${esc(r)}</span>${bar(n/mx,"acc")}<span class="num">${fI(n)}</span></div>`).join("")}</div>`:'<p class="mute">Nothing posted today yet.</p>')+`<p class="note">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.</p>`
};
}