Fleet calculator: material, energy and labour cost, revenue and P&L
Deploy test site / deploy (push) Successful in 36s

User, October 2026: add calculation for material cost, PVC, carton,
chemicals, labour etc. and revenue from sales.

New "Money a year" card (and a "Prices and money" input card):
- revenue: standard records at the standard price, specials with the
  premium, title services per title (revenue-model.md 3.50 to 4.60
  blended, +0.60 to 1.70, 580 to 1,730 USD a title);
- materials and packaging: PVC from the compound tonnage (rejects in,
  trim back as regrind), labels per pressed record, inner sleeves,
  printed jackets, shrink wrap, shipping cartons (estimate), maintenance;
- stamper making: lacquer blanks and styli per side (estimate), nickel,
  silver and chemistry per electroformed part (revenue model: 60 to 150
  USD a three-generation set);
- energy from the fleet: machines, chiller and compressors at the hours
  run plus an estimated base load, roof PV off, PEA 3.2.2 blended rate and
  demand charge; steam from the presses running time at the biomass heat
  price (biomass.md); the start fleet lands in scale.md 4.3 to 6.4M THB;
- loaded labour per role (revenue-model.md wages; cutting engineer an
  estimate), headcount now also counts QC, maintenance and cutting;
- overheads, EBITDA, depreciation of capex (total-investment.md start
  fleet plan plus or minus each Duplex system and Classic), operating
  profit, payback; per-record cost bar;
- Adverse / Central / Favourable presets from the revenue model ranges,
  USD / THB / EUR display; ladder gains revenue and EBITDA columns.
At the defaults (1 M records, 20 % specials): revenue 4.42M USD, direct
cost 1.86 USD a record (revenue model central 1.94), EBITDA 2.23M.

README bullet; revenue-model.md links the calculator from its operating
picture.
This commit is contained in:
grabowski committed 2026-10-10 19:41:08 +07:00
1 parent 7d7a378c8b
commit ce1d71e0b5
3 files changed
+223 -14

No files matched your search

+213 -11
View File
@@ -52,6 +52,9 @@ document.documentElement.setAttribute("data-theme",t);})();
.pk-pipe { fill: none; stroke-width: 3; stroke-dasharray: 8 5; opacity: 0.85; }
.pk-steam { stroke: #e2462a; } .pk-cool { stroke: #2f7de1; } .pk-hyd { stroke: #d48600; }
.pk-air { stroke: #8a97b0; } .pk-pow { stroke: #c9a800; }
tr.tot td { font-weight: 600; border-top: 1px solid var(--stroke); }
td.neg { color: var(--bad); }
#ecoBar div { min-width: 0; }
.themebtn {
position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg);
border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer;
@@ -150,7 +153,7 @@ document.documentElement.setAttribute("data-theme",t);})();
.grid > div { min-width: 0; }
.tscroll { overflow-x: auto; }
.tscroll table { min-width: 560px; }
#ladT { min-width: 860px; }
#ladT { min-width: 1040px; }
</style>
</head>
<body>
@@ -215,6 +218,26 @@ document.documentElement.setAttribute("data-theme",t);})();
<label class="row"><span>Overall effectiveness, %<br><small>scale.md: 60 to 70 % (changeovers, stamper changes, QC, breaks)</small></span><input type="number" id="eff" min="1" max="100" step="1"></label>
</section>
<section class="card">
<h2>Prices and money</h2>
<div class="seg" id="case">
<button data-v="adverse">Adverse</button>
<button data-v="central">Central</button>
<button data-v="favourable">Favourable</button>
</div>
<p class="hint" style="margin-bottom:6px">The price and cost ends of the revenue model; editing a figure makes it your own case.</p>
<label class="row"><span>Standard record, USD<br><small>black or single colour; revenue model: 3.50 to 4.60 blended</small></span><input type="number" id="priceStd" min="0" step="0.05"></label>
<label class="row"><span>Specials premium, USD<br><small>splatter and effects: +0.60 to 1.70</small></span><input type="number" id="premSpec" min="0" step="0.05"></label>
<label class="row"><span>Title services, USD a title<br><small>mastering, cutting, stampers, test pressings: 580 to 1,730</small></span><input type="number" id="titleFee" min="0" step="5"></label>
<div class="row" style="margin-top:12px"><span>Show money in</span></div>
<div class="seg" id="cur">
<button data-v="USD">USD</button>
<button data-v="THB">THB</button>
<button data-v="EUR">EUR</button>
</div>
<p class="hint" id="priceHint"></p>
</section>
<section class="card">
<h2>Press rates</h2>
<label class="row"><span>Duplex system, records per minute<br><small>both presses together; 3 = 40 s per press cycle. Newbilt quotes about 30 s per press</small></span><input type="number" id="rateD" min="0" step="0.1"></label>
@@ -238,6 +261,19 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="tscroll" style="margin-top:12px"><table id="mixT"></table></div>
</section>
<section class="card">
<h2>Money a year</h2>
<div class="kpis" id="ecoK"></div>
<div class="bar" id="ecoBar" style="height:28px"></div>
<div class="legend" id="ecoLeg"></div>
<div class="tscroll" style="margin-top:12px"><table id="ecoT"></table></div>
<p class="note" style="margin-bottom:0">Central is the revenue model's midpoint. The revenue model itself calls
that case optimistic: its EBITDA sits well above the 15 to 25 % pressing plants earn, because the cost
table still misses indirect labour, inbound freight, inventory, effluent and sales commission. Read the
adverse case as the honest one until those lines exist. Prices are a Western benchmark; Thai domestic
invoices run far higher (650 to 950 THB all-in at 300 units).</p>
</section>
<section class="card">
<h2>What the fleet needs</h2>
<div class="tscroll"><table id="reqT"></table></div>
@@ -290,9 +326,16 @@ document.documentElement.setAttribute("data-theme",t);})();
<p><b>Peaks add up.</b> Steam, cooling and air are summed at each machine's peak, as Newbilt
states them. A real plant staggers cycles, so the boiler and chiller can be sized lower once
a utility engineer has the cycle data.</p>
<p><b>Not scaled here:</b> mastering (1 to 2), QC (1 to 2), maintenance (2) and admin, sales and
logistics (3 to 5), from scale.md; cover for leave and sickness; the boiler fuel and the PV
share of the power.</p>
<p><b>Money.</b> Prices, per-record costs, wages, overheads and depreciation come from
finance/revenue-model.md, capex from finance/total-investment.md (the start fleet plan plus each
Duplex system or Classic added or removed), the electricity tariff and PV from scale.md, the biomass
heat price from biomass.md. Electricity and steam follow the fleet: machines, chiller and compressors
at the hours run, steam from the presses' running time, plus an estimated base load for the rest of
the plant. Lacquer blanks, shipping cartons and the cutting engineer's wage have no figure on file
and are estimates. Missing as the revenue model lists: indirect labour, freight, inventory, effluent,
commission, extra galvanics units' capex.</p>
<p><b>Not scaled here:</b> admin, sales and logistics (in the overheads line); cover for leave
and sickness.</p>
<p>Sources in the CNX Vinyl planning repository: equipment/pressing-line.md,
equipment/newbilt/ (Duplex site requirements V14, offer 240620-04, 2026 flyers),
equipment/galvanics.md (cycle times per step; the stamper line counts each station from them,
@@ -307,6 +350,7 @@ document.documentElement.setAttribute("data-theme",t);})();
const S0 = {
mode: "target", target: 1000000, spec: 20, grow: "classic", dMin: 2, cMin: 1,
dFleet: 2, cFleet: 1, shifts: 2, hrs: 8, days: 300, eff: 65, rateD: 3, rateC: 2, specF: 100,
priceStd: 3.8, premSpec: 1.15, titleFee: 1155, cur: "USD",
};
// [group, id, label, value, unit, kind, source]
const AS = [];
@@ -375,6 +419,46 @@ grp("Mastering, galvanics and stamper prep", [
["vspMin", "Centre punch per stamper (Sibert VSP)", 4, "min", "est", "galvanics.md: 3 to 5 min, estimate"],
["vscMin", "Coin per stamper (Sibert VSC)", 2, "min", "est", "galvanics.md: 1 to 2 min, estimate"],
]);
grp("Money", [
["thbUsd", "THB per USD", 33, "THB", "doc", "revenue-model.md and total-investment.md, FX of 5 August 2026"],
["usdEur", "USD per EUR", 1.155, "USD", "doc", "revenue-model.md"],
["overheads", "Cash overheads a year", 330000, "USD", "doc", "revenue-model.md: 200 to 450k (admin, sales, insurance, facility, IT, fees, marketing)"],
["capexBase", "Capex, start fleet plan", 6800000, "USD", "doc", "total-investment.md: 4.7 to 8.9M full vision; added scope not yet rolled up"],
["capexD", "Capex per Duplex system beyond two", 265000, "USD", "doc", "total-investment.md: system 2 ~230 to 300k, estimate"],
["capexC", "Capex per Classic beyond one", 375000, "USD", "doc", "total-investment.md: ~290 to 460k, no Newbilt price on file"],
["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
]);
grp("Materials and packaging", [
["pvcPrice", "PVC compound", 2.3, "USD/kg", "doc", "revenue-model.md: 1.75 to 2.90 USD/kg record grade"],
["labelCost", "Labels, a pair", 0.06, "USD", "doc", "revenue-model.md: 0.03 to 0.09"],
["sleeveCost", "Inner sleeve", 0.13, "USD", "doc", "revenue-model.md: 0.09 to 0.17, poly-lined"],
["jacketCost", "Jacket (printed carton)", 0.66, "USD", "doc", "revenue-model.md: 0.40 to 0.92, local carton partners"],
["shrinkCost", "Shrink wrap", 0.025, "USD", "doc", "revenue-model.md: 0.02 to 0.03"],
["cartonCost", "Shipping cartons and pallets, a record", 0.04, "USD", "est", "Estimate: a 25-record shipping carton at about 1 USD"],
["maintCost", "Maintenance, spares, consumables, a record", 0.17, "USD", "doc", "revenue-model.md: 0.12 to 0.23"],
]);
grp("Stamper making", [
["lacquerCost", "Lacquer blank and stylus wear, a side", 45, "USD", "est", "Estimate; no lacquer or stylus quote on file"],
["chemPart", "Nickel, silver, chemistry, an electroformed part", 25, "USD", "doc", "revenue-model.md: 60 to 150 USD marginal per three-generation set, so 20 to 50 a part"],
]);
grp("Energy", [
["kwhPrice", "Electricity, blended", 3.81, "THB/kWh", "doc", "scale.md: PEA TOU 3.2.2 at two shifts, before the demand charge (Ft September to December 2026)"],
["demandPrice", "Demand charge", 132.93, "THB/kW a month", "doc", "PEA 3.2.2 (scale.md)"],
["restKW", "Rest of the plant while running", 120, "kW", "est", "Estimate: HVAC, lighting, galvanics, finishing, offices; puts the start fleet in scale.md's 4.3 to 6.4M THB a year before PV"],
["pvKWp", "Roof PV", 247, "kWp", "doc", "scale.md"],
["pvYield", "PV yield, all self-consumed", 1400, "kWh/kWp", "doc", "scale.md: 1,400 to 1,500 kWh/kWp a year in northern Thailand"],
["heatPrice", "Steam heat from biomass", 0.7, "THB/kWh", "doc", "biomass.md: 0.5 to 0.9 at 1.35 to 2 THB/kg delivered"],
["steamKWh", "Heat per kg of steam", 0.68, "kWh/kg", "est", "Estimate: 10 bar saturated steam from ~80 °C feed water"],
]);
grp("Labour (THB a month a head, loaded ×1.3)", [
["wageOp", "Press and finishing operator", 19500, "THB", "doc", "revenue-model.md labour build-up"],
["wageQC", "QC", 26000, "THB", "doc", "revenue-model.md"],
["wageMaint", "Maintenance", 32500, "THB", "doc", "revenue-model.md"],
["wageGalv", "Galvanics", 32500, "THB", "doc", "revenue-model.md"],
["wageCut", "Cutting engineer", 45000, "THB", "est", "Estimate; the revenue model counts mastering as title-services labour"],
["qcPerShift", "QC inspectors per shift", 1, "", "est", "Estimate; scale.md: 1 to 2 QC"],
["pressPerTech", "Presses per maintenance technician (at least 2)", 8, "", "est", "Estimate; scale.md: 2 in maintenance for the start fleet"],
]);
grp("Planned plant, for the checks", [
["boiler", "Steam boiler", 400, "kg/h", "doc", "scale.md: sized for one Duplex system"],
["chiller", "Chiller", 250, "kW", "doc", "scale.md: sized for one Duplex system"],
@@ -382,6 +466,22 @@ grp("Planned plant, for the checks", [
]);
const A0 = Object.fromEntries(AS.map(r => [r[1], r[3]]));
const KIND = { vendor: "vendor", doc: "doc", est: "estimate", user: "your figure" };
// revenue-model.md ranges: adverse pairs low price with high cost, favourable the reverse
const CASES = {
adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580 },
A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, capexBase: 8900000, heatPrice: 0.9, deprYears: 10 } },
central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155 },
A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, capexBase: 6800000, heatPrice: 0.7, deprYears: 12.5 } },
favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730 },
A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, capexBase: 4700000, heatPrice: 0.5, deprYears: 15 } },
};
function caseNow() {
for (const k in CASES) {
const c = CASES[k];
if (Object.keys(c.S).every(x => S[x] === c.S[x]) && Object.keys(c.A).every(x => A[x] === c.A[x])) return k;
}
return "";
}
const LADDER = [
["Start fleet (current plan)", 2, 1], ["Step 1", 2, 3], ["Step 2", 2, 5], ["Step 3", 2, 8],
["Full hall, Classic-led", 2, 11], ["Full hall, mixed", 4, 7], ["Full hall, specials-led", 6, 3],
@@ -476,6 +576,50 @@ function compute(D, C, X, b) {
trimT: X * (1 + A.reject / 100) * A.trim / 1e6,
};
}
function headsOf(r) {
return {
ops: r.staff, qc: r.X > 0 ? Math.ceil(A.qcPerShift * S.shifts) : 0, galv: r.galv,
maint: r.P > 0 ? Math.max(2, Math.ceil(r.P / Math.max(1, A.pressPerTech))) : 0, cut: r.gst.lathe.n,
};
}
// Money a year in USD. Lines: [group, label, USD, basis].
function econ(r, b) {
const fx = A.thbUsd > 0 ? A.thbUsd : 33, t2u = v => v / fx;
const X = r.X, pressed = X * (1 + A.reject / 100), st = X - r.sp, L = [];
const add = (g, l, v, s) => L.push([g, l, v, s]);
const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
const rev = revStd + revSpec + revTitle;
add("mat", "PVC compound", r.compound * 1000 * A.pvcPrice, `${f0(r.compound)} t at ${f2(A.pvcPrice)} USD/kg incl. rejects; trim back as regrind`);
add("mat", "Labels", pressed * A.labelCost, "a printed pair per pressed record");
add("pack", "Inner sleeves", X * A.sleeveCost, "");
add("pack", "Jackets (printed cartons)", X * A.jacketCost, "local carton partners");
add("pack", "Shrink wrap", X * A.shrinkCost, "");
add("pack", "Shipping cartons and pallets", X * A.cartonCost, "estimate");
add("maint", "Maintenance, spares, consumables", X * A.maintCost, "");
add("metal", "Lacquer blanks and styli", r.titles * 2 * A.lacquerCost, `${f0(r.titles * 2)} lacquer sides`);
add("metal", "Nickel, silver, plating chemistry", r.partsYr * A.chemPart, `${f0(r.partsYr)} electroformed parts`);
const cap = capacityOf(r.D, r.C, b), use = cap > 0 ? Math.min(1, X / cap) : 0;
const runH = b.hoursYr * S.eff / 100 * use; // press running hours a year
const steamT = r.P * (A.steamLo + A.steamHi) / 2 * runH / 1000;
const heatMWh = steamT * A.steamKWh;
const elecMWh = X > 0 ? r.mwh + heatMWh / Math.max(0.5, A.cop) + (r.compKW + A.restKW) * b.hoursYr / 1000 : 0;
const pvMWh = Math.min(elecMWh, A.pvKWp * A.pvYield / 1000);
const demandKW = X > 0 ? r.kw + r.chillerKW + r.compKW + A.restKW : 0;
add("energy", "Electricity", t2u((elecMWh - pvMWh) * 1000 * A.kwhPrice + demandKW * A.demandPrice * 12),
`${f0(elecMWh - pvMWh)} MWh from the grid after ${f0(pvMWh)} MWh roof PV; ~${f0(demandKW)} kW demand`);
add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat`);
const h = headsOf(r), wage = (n, w) => t2u(n * w * 12), ppl = n => n + (n === 1 ? " person" : " people");
add("lab", "Press operators and finishing", wage(h.ops, A.wageOp), ppl(h.ops));
add("lab", "QC", wage(h.qc, A.wageQC), ppl(h.qc));
add("lab", "Maintenance", wage(h.maint, A.wageMaint), ppl(h.maint));
add("lab", "Galvanics", wage(h.galv, A.wageGalv), ppl(h.galv));
add("lab", "Cutting engineers", wage(h.cut, A.wageCut), ppl(h.cut));
const sum = gs => L.filter(l => gs.includes(l[0])).reduce((a, l) => a + l[2], 0);
const direct = L.reduce((a, l) => a + l[2], 0), contrib = rev - direct, ebitda = contrib - A.overheads;
const capex = A.capexBase + (r.D - 2) * A.capexD + (r.C - 1) * A.capexC, depr = capex / Math.max(1, A.deprYears);
return { rev, revStd, revSpec, revTitle, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h };
}
function scenario() {
const b = basis(S);
let D, C, X, cap;
@@ -495,6 +639,14 @@ const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minim
const f0 = x => nf(0).format(Math.round(x));
const f1 = x => nf(1).format(x);
const big = x => x >= 1e6 ? nf(2).format(x / 1e6) + " M" : f0(x);
const f2 = x => nf(2).format(x);
const fix2 = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const cv = usd => S.cur === "THB" ? usd * A.thbUsd : S.cur === "EUR" ? usd / Math.max(0.01, A.usdEur) : usd;
function money(usd) {
const v = cv(usd), a = Math.abs(v);
return (a >= 1e6 ? nf(2).format(v / 1e6) + " M" : a >= 1e4 ? f0(v / 1e3) + " k" : f0(v)) + " " + S.cur;
}
const unit = usd => fix2.format(cv(usd)) + " " + S.cur;
const pct = x => Math.round(x * 100) + " %";
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const $ = id => document.getElementById(id);
@@ -506,7 +658,9 @@ function render() {
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
for (const g of ["mode", "grow"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
for (const g of ["mode", "grow", "cur"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
const cs = caseNow();
for (const bt of $("case").children) bt.classList.toggle("on", bt.dataset.v === cs);
$("fleetK").innerHTML =
kpi(r.D, `Duplex systems<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmers`, "d") +
@@ -552,6 +706,46 @@ function render() {
`<tr><td>Duplex system (2 presses)</td><td class="n">${r.D}</td><td class="n">${big(r.D * b.capD)}</td><td class="n">${big(r.sp + r.stD)}</td><td class="n">${pct(r.dUse)}</td><td class="n">${pct((r.sp + r.stD) / W)}</td></tr>` +
`<tr><td>Classic</td><td class="n">${r.C}</td><td class="n">${big(r.C * b.capC)}</td><td class="n">${big(r.stC)}</td><td class="n">${pct(r.cUse)}</td><td class="n">${pct(r.stC / W)}</td></tr></tbody>`;
// ---- money
const e = econ(r, b), Xr = Math.max(1, r.X), perRec = usd => r.X > 0 ? unit(usd / Xr) : "–";
const blended = r.X > 0 ? (e.revStd + e.revSpec) / r.X : S.priceStd;
$("priceHint").textContent = `Blended record price at this mix: ${unit(blended)}. Prices are entered in USD; 1 USD = ${f2(A.thbUsd)} THB, 1 EUR = ${nf(3).format(A.usdEur)} USD.`;
$("ecoK").innerHTML =
kpi(money(e.rev), "revenue a year") +
kpi(money(e.ebitda), `EBITDA, ${e.rev > 0 ? pct(e.ebitda / e.rev) : "0 %"} of revenue`) +
kpi(money(e.op), "operating profit after depreciation") +
kpi(perRec(e.direct), "direct cost per record") +
kpi(isFinite(e.payback) ? f1(e.payback) + " years" : "never", `payback on ${money(e.capex)} capex`);
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
["Lacquers and plating", e.sum(["metal"]), "#8e98a6"], ["Energy", e.sum(["energy"]), "#e2462a"],
["Labour", e.sum(["lab"]), "#2f7de1"], ["Maintenance", e.sum(["maint"]), "#7a6fd6"],
["Overheads", A.overheads, "#6b7a90"], ["Depreciation", e.depr, "#4a5f85"]];
const costAll = SEG.reduce((a, x) => a + x[1], 0), scale = Math.max(e.rev, costAll, 1);
$("ecoBar").innerHTML = SEG.map(([l, v, c]) => `<div style="width:${100 * v / scale}%;background:${c}" title="${l}: ${unit(v / Xr)} a record"></div>`).join("") +
(e.op > 0 ? `<div style="width:${100 * e.op / scale}%;background:var(--accent)" title="Operating profit"></div>` : "");
$("ecoLeg").innerHTML = `<span>Per record, revenue ${perRec(e.rev)}:</span>` +
SEG.map(([l, v, c]) => `<span><i style="background:${c}"></i>${l} ${perRec(v)}</span>`).join("") +
(e.op >= 0 ? `<span><i style="background:var(--accent)"></i>Profit ${perRec(e.op)}</span>`
: `<span style="color:var(--bad)">Loss ${perRec(-e.op)} a record</span>`);
const P = [];
const PG = t => P.push(`<tr class="grp"><td colspan="4">${t}</td></tr>`);
const PR = (l, v, s, tot) => P.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n${v < 0 ? " neg" : ""}">${money(v)}</td><td class="n${v < 0 ? " neg" : ""}">${perRec(v)}</td><td class="src">${s || ""}</td></tr>`);
PG("Revenue");
PR("Standard records", e.revStd, `${big(r.X - r.sp)} at ${unit(S.priceStd)}`);
PR("Splatter and specials", e.revSpec, `${big(r.sp)} at ${unit(S.priceStd + S.premSpec)}`);
PR("Title services", e.revTitle, `${f0(r.titles)} titles at ${money(S.titleFee)}`);
PR("Revenue", e.rev, "", true);
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Stamper making", ["metal"]], ["Energy", ["energy"]], ["Labour, loaded wages", ["lab"]]];
for (const [t, gs] of GRP) { PG(t); e.L.filter(l => gs.includes(l[0])).forEach(l => PR(l[1], l[2], l[3])); }
PG("Result");
PR("Direct cost", e.direct, "everything above", true);
PR("Contribution", e.contrib, e.rev > 0 ? pct(e.contrib / e.rev) + " of revenue" : "", true);
PR("Overheads", A.overheads, "admin, sales, insurance, facility, IT, fees, marketing");
PR("EBITDA", e.ebitda, e.rev > 0 ? pct(e.ebitda / e.rev) + " of revenue" : "", true);
PR("Depreciation", e.depr, `${money(e.capex)} capex over ${f1(A.deprYears)} years`);
PR("Operating profit", e.op, isFinite(e.payback) ? `payback ${f1(e.payback)} years on EBITDA` : "no payback", true);
$("ecoT").innerHTML = `<thead><tr><th>Line</th><th class="n">A year</th><th class="n">A record</th><th>Basis</th></tr></thead><tbody>${P.join("")}</tbody>`;
const rows = [];
const G = t => rows.push(`<tr class="grp"><td colspan="3">${t}</td></tr>`);
const R = (l, v, s) => rows.push(`<tr><td>${l}</td><td class="n">${v}</td><td class="src">${s || ""}</td></tr>`);
@@ -560,8 +754,11 @@ function render() {
R("Classic attendants, per shift", r.att, `one per ${f0(A.cPerAtt)} Classics (assumption)`);
R("Finishing: inspection, sleeving, packing, per shift", r.fin, `at ${f0(A.finRate)} records an hour per person (estimate)`);
R("Production staff on all shifts", r.staff, `${r.perShift} per shift × ${S.shifts}; add cover for leave`);
R("QC inspectors, all shifts", e.heads.qc, `${f1(A.qcPerShift)} per shift (estimate; scale.md 1 to 2)`);
R("Maintenance technicians", e.heads.maint, `one per ${f0(A.pressPerTech)} presses, at least 2 (estimate)`);
R("Galvanics operators (day work)", r.galv, `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)`);
R("Not scaled here", "7 to 11", "mastering, QC, maintenance, admin and logistics (scale.md)");
R("Cutting engineers (day work)", e.heads.cut, "one per lathe (estimate)");
R("Production headcount", e.heads.ops + e.heads.qc + e.heads.maint + e.heads.galv + e.heads.cut, "admin, sales and logistics sit in overheads; add cover for leave");
G("Machines");
R("HRP 12 presses (Duplex)", 2 * r.D, "");
R("Classic automatic presses", r.C, "extruder built in; trimming not stated by Newbilt");
@@ -608,11 +805,11 @@ function render() {
$("reqT").innerHTML = `<tbody>${rows.join("")}</tbody>`;
$("ladT").innerHTML =
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th></tr></thead><tbody>` +
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th><th class="n">Revenue</th><th class="n">EBITDA</th></tr></thead><tbody>` +
LADDER.map(([n, d, c], i) => {
const x = capacityOf(d, c, b), q = compute(d, c, x, b);
const x = capacityOf(d, c, b), q = compute(d, c, x, b), qe = econ(q, b);
const cur = r.D === d && r.C === c ? " cur" : "";
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td></tr>`;
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td><td class="n">${money(qe.rev)}</td><td class="n${qe.ebitda < 0 ? " neg" : ""}">${money(qe.ebitda)}</td></tr>`;
}).join("") + "</tbody>";
drawPark(r, b);
@@ -755,7 +952,7 @@ function drawPark(r, b) {
}
// ---------------------------------------------------------------- inputs and state
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF"];
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF", "priceStd", "premSpec", "titleFee"];
function syncInputs() {
for (const k of NUM) $(k).value = S[k];
$("targetR").value = S.target;
@@ -787,6 +984,7 @@ function readHash() {
}
if (!["target", "fleet"].includes(S.mode)) S.mode = "target";
if (!["classic", "duplex"].includes(S.grow)) S.grow = "classic";
if (!["USD", "THB", "EUR"].includes(S.cur)) S.cur = "USD";
}
function num(el, fallback) { const v = parseFloat(el.value); return isFinite(v) ? v : fallback; }
@@ -796,7 +994,11 @@ syncInputs();
for (const k of NUM) $(k).addEventListener("input", e => { S[k] = num(e.target, S0[k]); if (k === "target") $("targetR").value = S.target; render(); });
$("targetR").addEventListener("input", e => { S.target = +e.target.value; $("target").value = S.target; render(); });
$("spec").addEventListener("input", e => { S.spec = +e.target.value; render(); });
for (const g of ["mode", "grow"]) $(g).addEventListener("click", e => {
$("case").addEventListener("click", e => {
const v = e.target.dataset && e.target.dataset.v; if (!v || !CASES[v]) return;
Object.assign(S, CASES[v].S); Object.assign(A, CASES[v].A); syncInputs(); render();
});
for (const g of ["mode", "grow", "cur"]) $(g).addEventListener("click", e => {
const v = e.target.dataset && e.target.dataset.v; if (!v) return;
if (g === "mode" && v === "fleet" && S.mode === "target") { const { r } = scenario(); S.dFleet = r.D; S.cFleet = r.C; syncInputs(); }
S[g] = v; render();