An open OEE engine: building ShopFloor API next to a production MES

At Kingsley Beverage in Dubai I am the sole developer of the MES/ERP platform that runs the plant's daily reporting. That system is private - it carries real production data and will stay behind the company's walls. Which creates a familiar portfolio problem: the work I am most qualified to talk about is the work I cannot show.

ShopFloor API is the answer: the same manufacturing domain - lines, job orders, downtime, OEE - rebuilt from scratch in the open, as a Spring Boot 3 / Java 21 backend with PostgreSQL, Flyway, JWT roles and a live Swagger UI. Not a copy of the production code (none of it is), but the same problems, solved where anyone can read the solution.

1. OEE in one pure class

Overall Equipment Effectiveness is the manufacturing KPI: Availability × Performance × Quality. Each factor looks trivial until real shift data arrives. The whole calculation lives in one class with no injected collaborators - it is a Spring @Component, but it takes five ints and returns four numbers, so it is trivially unit-testable:

service/OeeCalculator.java

int totalUnits = goodUnits + rejectUnits;
int runTimeMinutes = Math.max(0, plannedRuntimeMinutes - downtimeMinutes);

BigDecimal availability = ratio(
        BigDecimal.valueOf(runTimeMinutes),
        BigDecimal.valueOf(plannedRuntimeMinutes));

BigDecimal idealMinutes = ratedUnitsPerHour <= 0
        ? BigDecimal.ZERO
        : BigDecimal.valueOf((long) totalUnits * 60)
                .divide(BigDecimal.valueOf(ratedUnitsPerHour), WORKING_SCALE, RoundingMode.HALF_UP);
BigDecimal performance = ratio(idealMinutes, BigDecimal.valueOf(runTimeMinutes));

BigDecimal quality = ratio(
        BigDecimal.valueOf(goodUnits),
        BigDecimal.valueOf(totalUnits));

The interesting part is what the helper refuses to do. ratio() guards divide-by-zero (a shift with zero planned minutes, a batch with zero units) and clamps every factor to [0, 1] - both bounds. The clamp matters because real lines sometimes run above their rated speed - operators push a machine past nameplate - and a naive Performance of 1.07 multiplies through to an OEE over 100%, which destroys the metric's credibility with the people it is supposed to convince.

Two honest caveats, because this is the paragraph that invites you to go and read the code.

The upper clamp is a defensible choice, not an obviously correct one. Standard OEE practice treats a Performance above 100% as a signal - it usually means the ideal cycle time or the unit count is misconfigured, not that the line beat physics. Silently clamping hides that. The version I would defend in a review is: clamp for display, keep the raw value, and raise a data-quality flag. The clamp is what ships today.

The divide-by-zero guard returns 0, and 0 is a real value. An unconfigured nameplate speed drives Performance to zero and reports a confident OEE of 0%, which looks identical to a catastrophic shift. That is the same failure I just argued against two paragraphs up: a number the reader cannot distinguish from a real one. The right shape is an explicit INSUFFICIENT_DATA rather than a zero, and the same goes for Math.max(0, planned - downtime), which quietly swallows a data-entry error where logged downtime exceeds planned runtime. Both are on the list.

BigDecimal with a fixed working scale, not double - though the usual argument for it does not survive contact with this code. ratio() divides at scale 8 and immediately rounds to 4, so each factor is rounded before the three are multiplied, which introduces about 1e-4 of error - more than double would at report precision. The real reason is not accuracy, it is determinism: the rounding is specified, identical on every machine and every JVM, and reproducible when somebody asks why last Tuesday's figure was 82.1% and not 82.2%. "Floating-point wobble" is the wrong justification; auditability is the right one.

2. Compute at the moment of truth, not on a timer

OEE is calculated when a job order closes - a state-machine transition (PLANNED → RUNNING → COMPLETED) - rather than by a scheduled job that sweeps the table. The production system taught me that: cron-style recomputation means a report pulled at 7:00 and one pulled at 7:20 can disagree, and the factory's trust in the number dies right there. Computing once, at close, from final inputs, makes the figure stable and auditable - the same event-driven shape the private MES uses.

3. A schema that cannot drift

Every table is created by a versioned Flyway migration, and Hibernate runs in validate mode - it checks the entity mapping against the migrated schema and refuses to boot on a mismatch, instead of silently "fixing" the database with ddl-auto. On a one-developer project this discipline looks like overhead until the first time it catches a column you renamed in the entity but not the migration. It boots clean on a fresh PostgreSQL every CI run, which is itself the test.

4. Tests that earn the green badge

The CI badge is only worth what runs under it: JUnit 5 unit tests on the calculator (the zero-division, over-speed and rounding cases each have one), MockMvc tests on the controllers and JWT role rules, and Testcontainers integration tests that start a real PostgreSQL in Docker - so the SQL, the migrations and the transaction boundaries are exercised against the same engine production would use, not H2's approximation of it.

5. What the factory version taught the open version

  • Downtime needs reason codes from day one. A single "downtime minutes" number answers "how bad"; only categorised reasons answer "what do we fix first".
  • Roles are a domain concept, not a security afterthought. An operator logs production; a supervisor approves it; a manager reads it. The JWT roles mirror the shop-floor hierarchy because that is what makes the data trustworthy.
  • FIFO inventory is harder than it looks. Consuming raw material against job orders in arrival order, with partial lots, is where spreadsheet logic quietly breaks - and exactly the kind of invariant a relational schema with constraints should own.

The repo is live - code, migrations, tests and the Swagger playground - at github.com/saad-mughal435/shopfloor-api. If you want to see the production story it shadows, the MES/ERP walkthrough shows the shape of the real platform without the real data.