Blog/ Services/ Job costing

Two jobs, same invoice, very different money

True job cost: burden, drive time, allocation

Every dispatch board treats a $480 diagnostic and a $480 equipment swap as the same job. One carries most of its ticket as profit, the other barely covers the hours it consumed. Here is the method for telling them apart, and the two details that decide the answer.

One measure: burdened labor including drive time, parts at cost, and overhead allocated per billable hour. Below: the rate table, the cost measure, why allocation basis reorders the whole report, and the grain trap that hides losing work inside a healthy average.

MA Mikhail Abovyan·founder, ACS ·July 2026 ·9 min
Job costing burdened rate overhead allocation field service DAX
Driver's seat The method in plain language, with a live job margin table you can re-sort. Want the engineering, switch to "Under the hood".
Under the hood The burdened rate as a table, the cost measure, the allocation choice, and computing margin at the right grain.

01 Revenue per job is known, profit per job is not

Field service software is built to dispatch and invoice, and it does both well. What it does not do is net out what the job consumed: the technician's fully loaded hours, the travel, the parts pulled off the truck, and the share of running the company that those hours absorbed.

So the business is managed on ticket averages. Busy months feel good, revenue grows year over year, and the money left at the end stays flat. Usually there is no single big leak. There are two or three job types quietly losing money on every call, and nothing in the reporting is capable of saying which.

"A job you priced from the invoice is a guess. A job you priced from the hours it eats is a decision."

02 Three components, two of them usually missing

True cost is labor plus parts plus overhead. The arithmetic is trivial; the definitions are where the money is.

03 The allocation choice changes the answer

Spread overhead evenly per job and a 19 hour installation absorbs the same overhead as a 1.2 hour maintenance visit, which flatters long work and punishes quick high ticket work. Allocate it per hour consumed and the table below reorders itself. Re-sort it and watch which job types move:

Job margin with fully loaded cost synthetic data
True cost includes burdened labor with drive time, parts at cost, and overhead per billable hour
Sort by
Job type Jobs Avg ticket Hours incl. drive True cost Profit / job Margin Total GP

Sort by margin and the diagnostic and maintenance rows drop to the bottom. That is expected and it is not a reason to stop selling them: their function is to create replacement work, not to carry the company. But it only holds if they actually convert, so the model has to link a diagnostic visit to the job that followed it. Once it does, a "low margin" job type often turns out to be the most valuable thing the business sells.

04 The same math changes how people are ranked

Ranking technicians by revenue rewards whoever gets dispatched to the biggest jobs. Ranking them by margin, hours per job and callback rate shows something else: the quiet one with a lower ticket average who never goes back twice is frequently the most profitable person on the roster. Callbacks are the cost nobody invoices.

Marketing gets the same treatment. Return per channel should be gross profit over spend, not revenue over spend. A channel that reliably books cheap low margin work looks like a winner on revenue and a loser on profit, and only one of those is true.
Want the engineering? Switch to "Under the hood" at the top right for the rate table, the cost measure and the grain trap.

01 Three systems, three grains

The data exists and it lives apart. The field platform holds jobs, revenue and assigned technician. Payroll holds hours and pay per period. Accounting holds parts and overhead accounts by month. Nothing joins them, and each is recorded at a different grain.

SourceGrainBridge key
Field platform: jobsone row per jobJobId
Field platform: dispatch logone row per tech per jobJobId + TechId
Payrollone row per tech per pay periodTechId + period
Accounting: overheadone row per account per monthperiod

The dispatch log is the bridge that makes everything else possible: it is the only place a technician hour is tied to the job it was spent on.

02 The burdened rate is a table

Not a constant in a formula. A rate per technician per period, so raises, insurance changes and a new truck all land in history correctly rather than retroactively rewriting last year:

-- Burdened hourly rate per technician, per period
Burdened rate =
VAR DirectPay = SUM( 'Payroll'[Wages] )
                + SUM( 'Payroll'[EmployerTaxes] )
                + SUM( 'Payroll'[Benefits] )
                + SUM( 'Payroll'[Insurance] )
VAR Vehicle   = SUM( 'TechCosts'[VehicleAndPhone] )
VAR PaidHours = SUM( 'Payroll'[PaidHours] )
RETURN
    DIVIDE( DirectPay + Vehicle, PaidHours )

03 The cost measure

Onsite and drive hours are summed before the multiplication, which is the whole point:

-- True job cost = burdened labor + parts + allocated overhead
True job cost =
VAR LaborCost =
    SUMX(
        'Job Labor',
        ( 'Job Labor'[OnsiteHours] + 'Job Labor'[DriveHours] )
        * RELATED( 'Technicians'[BurdenedRate] )
    )
VAR PartsCost = CALCULATE( SUM( 'Job Parts'[Cost] ) )
VAR Overhead  = [Billable hours on job] * [Overhead rate per hour]
RETURN
    LaborCost + PartsCost + Overhead

04 The allocation basis, in one line

Overhead for the period divided by billable hours in the same period, so the rate moves with utilization rather than with how many jobs happened to be booked:

-- Overhead rate per billable hour, current period
Overhead rate per hour =
    DIVIDE(
        CALCULATE( SUM( 'Overhead'[Amount] ) ),
        CALCULATE( SUM( 'Job Labor'[OnsiteHours] ) )
    )
A useful side effect. Because the rate is hours based, a slow month raises the overhead absorbed per hour automatically, which is the honest signal. Job count based allocation hides exactly that.

05 The grain trap

The most common error in a costing model is aggregating first and dividing last. Sum revenue, sum cost, divide, and you get a respectable company margin with the losing job types buried inside it. Compute per job, then aggregate:

-- Gross profit, computed per job then summed
Gross profit =
    SUMX(
        VALUES( 'Jobs'[JobId] ),
        [Job revenue] - [True job cost]
    )

The same discipline applies to margin percentages: never average a percentage. Recompute it from the aggregated numerator and denominator at whatever level you are displaying.

06 Estimating what never got booked

Missed calls are worth quantifying precisely because the number is uncomfortable. Built from the company's own averages rather than an industry benchmark, and presented as an order of magnitude with the assumptions visible:

-- Estimated revenue lost on unconverted inbound calls
Missed call exposure =
VAR Unbooked  = [Inbound calls] - [Calls converted to jobs]
VAR AvgValue  = DIVIDE( [Total revenue], [Job count] )
VAR CloseRate = [Booking rate]
RETURN
    Unbooked * AvgValue * CloseRate
Say what it is. This is an estimate with three assumptions in it, and it should be labelled as one. Its value is that a five figure annual number ends a debate about whether an overflow line is worth paying for.

07 What stays editable

Vehicle cost per technician, which accounts belong in the overhead base, the callback window, membership pricing: all in an input sheet, none in formulas. Change a cell, refresh, and every historical period recomputes on the new definition. That is what makes the model survive contact with a real business.

Let's cost your jobs properly, on your own data

In a free review we take a month of your dispatch and payroll exports and show the real margin by job type, with drive time and overhead where they belong. No obligation.

more on the topic  ·  All blog articles →
reading mode