Fleet calculator: material, energy and labour cost, revenue and P&L
Deploy test site / deploy (push) Successful in 36s
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:
1 parent
7d7a378c8b
commit
ce1d71e0b5
3 files changed
+223
-14
No files matched your search
@@ -182,8 +182,12 @@ The detail lives in dedicated documents:
|
||||
stampers that fleet needs, drawn as a live schematic of the machine
|
||||
park with the stamper line (lathe, Pheenix galvanics, Sibert prep)
|
||||
feeding it, each station counted from the galvanics cycle times;
|
||||
every assumption is editable, with its source, and a scenario can be
|
||||
shared by link
|
||||
plus the money a year: revenue (standard, specials, title services),
|
||||
PVC, labels, sleeves, jackets, cartons, lacquers, plating chemistry,
|
||||
electricity and steam, loaded labour, overheads, EBITDA, depreciation
|
||||
and payback, with the revenue model's adverse, central and favourable
|
||||
cases and USD, THB or EUR; every assumption is editable, with its
|
||||
source, and a scenario can be shared by link
|
||||
- [Test site](site/README.md): `site/` builds https://cnxvinyl.lol/, the
|
||||
internal test site with demo pages from `docs/` (the plant operation
|
||||
page first) and any static site we want to share by link; Gitea Actions
|
||||
|
||||
@@ -523,7 +523,10 @@ logistics sit in overheads below.
|
||||
|
||||
## Operating picture at full capacity
|
||||
|
||||
Built from the components above rather than asserted. Three scenarios,
|
||||
Built from the components above rather than asserted. The
|
||||
[press fleet calculator](../fleet-calculator.html) runs the same P&L for
|
||||
any fleet, output and specials share, with energy and headcount computed
|
||||
from the fleet and these three cases as presets. Three scenarios,
|
||||
each internally consistent: adverse pairs low volume with the low price
|
||||
and high cost, favourable does the opposite, central takes midpoints.
|
||||
Title-services contribution assumes 60–70% after lacquer blanks, styli
|
||||
|
||||
+213
-11
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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();
|
||||
|
||||
Reference in new issue
Block a user