// CASE STUDY — CLIENT SYSTEM

OBRAHUB

> Labor tracking and billing for industrial field work

Built for Engemil, an industrial maintenance and assembly contractor in Mogi Guaçu, Brazil — and shaped from the start to generalize to a sector that all runs the same cycle. It replaces paper → spreadsheet → retyping with one flow: import the spreadsheet engineering already fills in, keep every work package's balance as a live account, and export the monthly billing document byte-identical to the format the client signs.

  • Rails 8 monolith
  • In production
  • Active development

// 01 — THE PROBLEM

Money escaped in the retyping.

Engemil allocates crews inside its largest client's plant, and every month it has to prove — job by job, person by person, day by day — how many hours were worked and what they cost. That document is signed by both sides. Before the system, producing it looked like this:

The old cycle

  1. the foreman writes on paper who worked, in what role, for how many hours
  2. someone types that into a tracking spreadsheet
  3. at month end, someone retypes it into a second spreadsheet — the billing one, in the format the client accepts
  4. the client checks it cell by cell and signs

Retyping is where money escapes

An hour that never got transcribed never got billed, and nobody finds out — because there is nothing to compare it against.

There is no balance

The client approves work packages with budgeted hours. Knowing how much was left in a package meant adding up entries by hand.

The client's format is law

The billing spreadsheet has an exact shape — font, colour, number format, block position. A billing document that merely looks similar comes back.

// 02 — HOW THE RULES GOT HERE

Nothing here came from a written spec.

The system was drawn out in successive rounds of discussion, review and feedback with the person at Engemil who was responsible for producing all of this by hand — each round trading an assumption for a fact about the operation. The real artefacts served as the specification: the reference billing workbook, the engineering tracking spreadsheet, the master package sheet and the job register. Three rules that only ever surface in conversation with someone who has done the work:

The job has no type

The system briefly had Project#kind (ase | hh | pacote). It was wrong: the same job has package days, hourly days and extra-service days. What declares the nature of the work is the daily report, not the job record. The column was removed.

Two of the codes are not hours at all

FE and FO are codes, not time. And the night-shift differential is a 45% uplift on the rate, not time worked — adding it to the hours inflated the billing.

74 of 84 packages have no date

They carry the word “UNIFIED” where the issue date should be. The field became optional because of that, not out of modelling elegance.

// 03 — THE DOMAIN

A job, a day, a line.

Ten domain tables. The whole operation fits in one model, which is why it is one application and not several.

User (admin | supervisor | foreman)
 └─ ProjectSupervisor ── Project ── Package        (the budgeted slice)
                            └────── Rdo            (the day of work)
                                     └── WorkEntry (the line: who, role, hours)
Employee ── Position (role, hourly rate)
ImportBatch ── ImportError
Project · Package
Project is the job. Package is the slice the client asked to be quoted on its own — it carries estimated hours, sale value and billed value.
Rdo
The daily work report: job, period, who signs, description, photo. It declares the nature of the day — package (draws down a Package), hourly (draws down nothing), or authorised extra service, identified by its own code, which is the code the invoice cites.
WorkEntry
The line inside the report: person, role, date, hours, hour type, photo.
Employee
The person in the field — deliberately not a User. A crew of hundreds cannot become hundreds of logins nobody remembers to revoke.
Measurement
Not a table. A read object that turns a period's hours into an invoice, at today's prices.

The five hour types

The five hour typesfactor
hn1.0regular hour
fa1.6overtime, factor A
fb2.0overtime, factor B
an0.45night differential — an uplift, not time
fd1.0compensatory rest

Balance is a query, not a column

Used hours, remaining hours, progress and value-to-bill all come out of a query at read time. A 663-hour package that receives a 9-hour report shows 654 hours remaining immediately, with no column being written — and therefore none of the classic risk of a stored total drifting from the lines that make it up.

// 04 — ARCHITECTURE

One process, one database, one deploy.

It is a Rails monolith, and that is a choice rather than a limitation. The data is tightly related — an hour entry only exists inside a daily report, which only exists inside a job — and there are no two teams moving at different speeds. Splitting it into services would buy distributed transactions for a company that closes one billing cycle a month.

Models

The rules that always hold — validation, scoping, balance calculation.

Policies · 12

Who can see and do what, per record. The same policy scope that filters the list filters the autocomplete and the dashboard — a counter that sums hours the viewer may not read is a leak wearing the face of a metric.

Query objects

Measurement, ProjectWorkReport, DashboardReport, RdoQuery — reads that do not deserve to be models.

Importers · 12 and exporters · 5

One importer per input file format; exporters for the billing workbook, the covering letter and the printable daily report.

Three roles

Admin does everything and is the only one who grants access or sees package money. Supervisor gets the whole operation minus user creation. The foreman is in the field: signs the report, sees only where they are assigned.

No background processing — on purpose

The daily report is closed inside the request that saves it, and imports are pushed by the page itself, a chunk per submit. A worker polling the database would keep the container from sleeping, so the app would stay up around the clock waiting for one import a month.

Built on

  • Rails 8
  • PostgreSQL
  • Hotwire
  • Turbo
  • Stimulus
  • Importmap
  • Tailwind
  • Devise
  • Pundit
  • RSpec
  • Docker
  • Railway

// 05 — DECISIONS WORTH TELLING

The parts that cost the most to get right.

Four problems where the obvious implementation is the wrong one.

The client's workbook, cell by cell

“Identical” does not mean “similar”. The work was unzipping the reference .xlsx and reading the XML, because the eye does not catch what breaks: fills use theme colours with tint rather than RGB, so reading only the RGB attribute sees no fill at all; money uses the ACCOUNTING format, which is what pins the currency symbol to the left edge and the number to the right; and in the summary sheet each hour type occupies two columns, so writing into consecutive columns puts every value two cells to the left. There is a test that strictly parses the generated styles.xml, because an unescaped quote in a format code produces a file Excel refuses to open — and neither the reading library nor the content tests notice.

Importing twice must not duplicate

The idempotency key is a SHA-256 of the row: job, foreman, role, date and hour type. An upsert updates the same record instead of creating a second one. Without the hour type in the digest, a regular and an overtime entry on the same day collided and one vanished. A detail that cost real time: the digest carries the package id only when there is one — adding it unconditionally would change the digest of everything already stored, and the next upload of the same file would create a copy of every line.

The importer creates records, but never invents money

Missing jobs, roles, employees and users are created, and the screen says which ones — creating silently is what turns a misspelled role into a permanent record nobody can trace. A missing package, though, rejects the line: packages carry estimated hours and a sale value, so inventing one would write a budget nobody approved.

The phone is not a smaller version of the screen

The foreman is on site with a phone. A seven-column table behind horizontal scroll technically fits, and nobody drags to the status column without a header in view to know where they are. Below 640px every table becomes a labelled card, with the label coming from each cell's data attribute. A test fails any new table that skips it, because such a table would pass every request spec and still disappear on the phone.

// 06 — NUMBERS

Measured, not estimated.

Repository counts as of 30 Aug 2026, and figures from real use.

test examples, 0 failures
899test examples, 0 failures
domain tables (+3 Active Storage)
10domain tables (+3 Active Storage)
models · controllers · policies
17 · 15 · 12models · controllers · policies
importers and exporters
19importers and exporters
views
72views
migrations
37migrations
lines across app/ and spec/
~22.7klines across app/ and spec/
commits, 8–30 Aug 2026
129commits, 8–30 Aug 2026

From real use

11,551 h
of labour closed in a single month, each tied to the job, role and package that paid for it
216
daily reports built from the spreadsheet engineering already filled in — nobody typed a report
3 s
to import 1,395 entries, check them against the register and reject what does not match
24
sheets in the billing workbook, in the format the client signs

// 07 — WHERE IT STANDS

In production, and honestly unfinished.

A portfolio that only lists what worked does not survive the next question. Scale tests count queries: a listing has to cost the same with ten records and with ten thousand — a real import file reaches 8,504 daily reports, which is why batch closing is one SQL statement per slice rather than a walk record by record.

Done and in use

  • registers for jobs, packages, employees, roles and users
  • daily reports, entered by hand or in bulk from the programming sheet
  • spreadsheet import with a rejection report
  • billing calculated and exported to Excel in the client's exact format
  • printable daily report sheet
  • dashboard
  • mobile pass over the whole system

Still open

  • the PACKAGE lines of the billing document are not generated by the exporter yet — the data already exists, the layout mapping does not
  • two jobs remain in the repository with nothing enqueueing them: either async comes back on purpose, or they go
  • no browser tests — real horizontal scroll and mobile Safari zoom are still a manual check
  • standardising the register screens under an inherited CRUD is decided and not started

Where it is headed

It was built for one contractor, but the cycle it replaces is not specific to one: crews allocated inside a client's plant, hours proven month by month, a billing document in a format the client dictates. Generalizing it into a product for that sector is the direction — the multi-client work has not started, and nothing here is sold to a second company yet.