Field Operations Industry, chapter 05 of 26
Territories & Capacity
Where work can happen and who can do it: territory polygons, effective-dated zones, shifts, skills, licenses, capacity, and availability rules as data.
"Can someone come Tuesday morning?" sounds like a calendar question. It's really four questions. Is the property somewhere the business serves? Who works that area on Tuesday? Of those people, who's allowed to do this work, with the skills for the products and the licenses for the jurisdiction? And does anyone have room left on their route? A customer service rep can't answer that from memory once a company has more than a few branches, and neither can the customer portal or the batch scheduler. All three ask the same availability engine, and the engine answers from data: territories drawn as polygons, people with shifts, skills and licenses, and capacity rules. This chapter is that data, and how the engine combines it.
The Geography Hierarchy#
Field operations divide the map in nested layers, each with a different job:
| Level | What it is | What it owns |
|---|---|---|
| Region | A group of branches under one manager | Reporting, escalation, regional staff |
| Branch (service center, office) | A physical base with its own team and trucks | Time zone, holidays, business license, the default start location for routes |
| Area | A polygon the branch serves | State, whether it can be sold and serviced, how far technicians may travel into it |
| Zone | A slice of an area for routing, often one technician's patch | Assigned teams and a primary technician |
The branch is the operational unit. It has a time zone (routes and "today" are local), a set of holidays, the business or operator license the trade requires in its state, and the address trucks leave from in the morning. Areas are where the geography gets precise: the polygon that decides whether an address is served, and by which branch. Zones exist for routing. They keep a technician in the same neighborhoods week after week, which is what makes a recurring route dense and lets customers see the same face.
Keep legal geography separate from operational geography. States, counties and municipalities are jurisdictions, and licensing rules hang off them (Licenses & Permits): a technician may be licensed for the state but not for a city inside it that requires its own permit. Jurisdiction boundaries don't line up with your areas, and they shouldn't have to. Store both as polygons, and precompute which jurisdictions intersect each area. That turns "which licenses does this area require?" into a lookup instead of a geometry query on every booking.
Polygons, Not ZIP Codes#
Many field service tools define territory as lists of ZIP codes. ServiceTitan's zones, for example, are groups of ZIP codes that control where jobs are accepted. It's easy to start with, and every address has a ZIP. But ZIP codes are delivery routes for mail, not shapes drawn for driving. One ZIP can straddle a river with a single bridge, or run from a dense suburb into empty country. Sooner or later a branch needs a boundary that follows a highway, a mountain or a drive-time line, and that's a polygon.
With polygons, resolving an address is a geometry problem:
- Validate and geocode the address to a latitude and longitude. An unvalidated address is a guess, and a geocode on the wrong side of a boundary books the wrong branch (Customers, Accounts & Properties).
- Find the containing area with a point-in-polygon test. In PostGIS that's
ST_ContainsorST_Covers, and the difference matters at the edges.ST_Containsdoesn't count points lying exactly on the boundary, whileST_Coversdoes. A geocode snapped to a street that is the boundary is exactly that case, so useST_Coversand a tie-break rule, or an address on the line belongs to no one. - Store the result on the address: its area, and through it its branch and zone. Don't recompute it on every read.
The test is fast at scale because PostGIS spatial predicates use the GiST index automatically. The index stores each polygon's bounding box, finds the few candidates whose boxes overlap the point, and runs the exact test only on those.
Polygons need validation, because people draw them by hand:
- Overlaps within one level make an address belong to two areas. Detect them when a polygon is saved, and either reject the save or clip one polygon against the other, with the user confirming which wins.
- Gaps leave addresses that belong to nowhere. They show up as unserviceable addresses and as a "no area" skip reason in batch scheduling. Report them; don't silently assign the nearest area.
- Edits re-resolve addresses. Moving a boundary changes which branch serves the homes near it. Re-resolve the affected addresses in a background job, and treat the result as a change to those customers' service: their future visits may now belong to another branch's routes.
Can Sell, Can Service#
An area carries two flags that look redundant and aren't: can sell and can service. Most areas have both. The exceptions are the interesting part of the business:
- Selling ahead of service. Opening a market starts with sales, sometimes weeks before the first route runs. The area can be sold, but its first services are scheduled for when the branch opens.
- Servicing without selling. A market being wound down, or an area where sales are paused because the branch can't absorb more customers, keeps serving the customers it has.
- Neither, but known. An area drawn for planning, or one the business has left, so addresses in it get a clear "we don't serve this area" instead of an error.
The rule that matters: never let sales close a customer the business can't service. Every sales tool checks the can-sell flag, and every booking checks can-service, against the same resolved area.
Effective-Dated Configurations#
Territories change. A technician joins, and three zones are redrawn into four. Summer volume needs smaller zones; winter needs fewer, bigger ones. Making those changes in place breaks two things. Routes already built for next week suddenly refer to zones that no longer exist. And last month's reports can no longer say who covered what.
So zone assignments are effective-dated: each configuration of a branch's zones applies from a date, and it moves through three states:
| State | Meaning | Rules |
|---|---|---|
queued |
Takes effect on a future date | Editable, and the only state that can be deleted |
active |
In force today | One per branch at a time |
past |
Superseded | Immutable, because history depends on it |
This is Martin Fowler's temporal property pattern: a value that changes over time, queryable as of any date, with future changes scheduled in advance. The scheduler asks "which configuration applies on the date of this visit?", not "what's the configuration now?". A planner can draw next month's zones today, preview the routes they'd produce, and let the change take effect on the first of the month without anyone working at midnight. The same applies to team membership, a technician's home branch and capacity limits: anything that changes on a date and that history depends on gets an effective date (Time covers storing dates and times).
People: Shifts and Teams#
The other half of availability is people. A shift is one technician's availability on one date: start and end times, where the day starts and ends (home, or the branch), and a type:
| Shift type | What it's for |
|---|---|
| Standard | Regular recurring work |
| Initial | First visits for new customers, which take longer and set the relationship |
| Training | A new technician riding along; needs an assigned trainer |
| On call | Available for urgent or same-day work |
| Manual | A one-off created by a manager |
Shifts are generated, not typed. Each technician has a default schedule (Monday to Friday, 8 to 5, say), and the system materializes shifts from it a few weeks ahead. Then overrides apply in order of precedence: a temporary change (this Thursday off) beats a recurring one (every other Saturday, or the first Monday of the month), which beats the regular default. Time off and company holidays remove shifts; they shouldn't be appointments squeezed into a calendar.
Shift types do real work in scheduling. If initial visits need a longer slot, a different product mix and a technician who can explain the service, restrict them to initial-type shifts, and the slot search will only offer initial slots where those shifts exist. A trainee's shift belongs to the trainer's route, and the trainee can't complete visits alone (Proof of Service).
Teams group technicians under a supervisor, and like zones, membership is effective-dated: "who was on the north team in March?" has to have an answer when commissions or compliance reports ask.
Skills and Licenses#
Not every technician can do every job. Two different things restrict them, and they belong in different places:
- Skills are about the work: termite treatments, mosquito systems, rodent exclusion, wildlife, a commercial kitchen, a particular brand of equipment. Products and plans declare the skills they require. A visit requires the union of the skills of everything on it, and a technician qualifies only if they hold all of them.
- Licenses are about the law: whether this person may perform this work in this jurisdiction, today. They expire, they differ by state and city, and an apprentice may be allowed to work only under a licensed supervisor. Licenses & Permits covers the model. For scheduling, what matters is the output: a compliance projection per technician, area and role, saying whether they're compliant there, kept current as licenses are issued and expire.
Both are hard constraints. A visit offered to a technician who lacks a skill fails at the door; a visit offered to one who lacks the license is a violation, and in a regulated trade like pest control, a regulator can see it in the application records. Check both in the slot search, before a slot is offered, not when the route is dispatched.
Capacity#
A slot can be free on the calendar and still be unbookable, because the route behind it is full. Capacity is measured in one of two ways, and the choice follows the work:
- By stops: each route takes at most a set number of visits. This suits recurring service, where visits are similar in length, and it's the number routing and branch managers actually think in. Add a hard per-technician daily cap on top, whatever the route says, so no optimization run ever books someone into an impossible day.
- By hours: capacity is labor time, consumed by each job's estimated duration. This suits repair and installation work, where one job takes 30 minutes and the next takes six hours. ServiceTitan's capacity model works this way: capacity provided by technician shifts, minus capacity consumed by booked jobs, leaves open capacity in hours.
Either way, the visit's duration has to fit the gap it's placed in. Durations come from the plan: an initial visit, a standard visit and a rework visit each take different times, and when one visit covers several services, the plan decides whether durations add up or the longest one wins.
Two more controls sit on routes:
- Locks. A locked route is frozen: invisible to scheduling, to optimization and to self-service. Lock a route when it's being dispatched, or when the technician is already on it, so a customer's online reschedule can't land in the middle of a day already in motion.
- Distance thresholds. A technician's day starts somewhere. A branch, or an area, sets how far from that base a visit may be, so a slot 40 miles away isn't offered just because the calendar is empty.
Availability Rules as Data#
Everything above becomes a pipeline of rules applied to candidate slots. Start with every gap on every route inside the visit's window, then remove the slots that break a rule, in priority order:
Keep the rules as data, not as code paths: each rule has a type, parameters, a priority, a scope (all branches, one branch, one booking channel) and an effective date range. A branch that needs a tighter distance limit, or a pilot of a new channel restriction, is a row with dates, not a deploy. A rule can look like this:
- id: phone-bookings-near-existing-stops
description: Phone bookings only on routes already in the neighborhood
applies_to:
channels: [inside_sales, customer_service]
condition:
max_distance_from_existing_stop_miles: 5
reject_reason: too_far
priority: 60
effective_from: 2026-04-01
That example is a real kind of rule. Different booking channels deserve different freedom. A customer on the portal picking any open slot is fine, but a rep booking over the phone into an empty route on the far side of the branch quietly wrecks the density that makes routes profitable. Channel rules keep each channel booking where the business wants it (Booking Appointments covers the channels).
Customer preferences come last, and they rank rather than reject. A Tuesday-morning customer offered a Wednesday afternoon still gets a visit, and the preference counts against the slot's score instead of removing it. Only a hard constraint the customer set ("never on Mondays, I'm not home") becomes a rejection.
Why Is It Empty?#
The most common question an availability engine gets isn't "what's open?". It's "why is nothing open?": from a customer service rep with an angry customer on the line, from a branch manager whose batch run skipped 200 visits, from an engineer debugging a ticket. An engine that just returns an empty list leaves all of them guessing.
So build an explain mode into the same pipeline. Pass a flag (discover_reason=true, say), and instead of only the surviving slots, the engine returns every candidate it considered, with the first rule that rejected it. Aggregated, that's a diagnosis:
{
"slots": [],
"rejected": {
"route_at_capacity": 31,
"not_compliant": 12,
"slot_too_short": 4
}
}
That reads as "every route is full, and the 12 technicians with room aren't licensed in this city", which is actionable: add capacity, or get the license. It's the same reasons batch scheduling records on its skips, from the same code. A rule that rejects without a reason is a bug.
What This Means for the System You Build#
- A geography hierarchy of region, branch, area and zone, with jurisdictions as a separate polygon layer and their intersections with areas precomputed.
- Polygons with point-in-polygon resolution (
ST_Coverswith a tie-break), spatially indexed, stored on the address, and re-resolved when boundaries move. - Overlap and gap validation when polygons are saved.
- Separate can-sell and can-service flags on every area, checked by sales and by booking.
- Effective-dated zone configurations (queued, active, past, with past immutable), and effective-dated team membership.
- Shifts generated from default schedules, with typed shifts and overrides by precedence.
- Skills and license compliance as hard constraints, checked before a slot is offered.
- Capacity by stops or by hours, a per-technician daily cap, route locks and distance thresholds.
- Availability rules as data, with priority, scope and effective dates, and an explain mode that says which rule removed each slot.
References#
Reference
- PostGIS: ST_Contains, containment excluding the boundary, with automatic spatial index use
- PostGIS: ST_Covers, containment including points on the boundary
- PostGIS Workshop: Spatial Indexing, GiST indexes of bounding boxes and the two-pass query
- Martin Fowler: Temporal Property, values that change over time, queried as of a date with future changes scheduled
In practice
- ServiceTitan: Set up capacity and availability with Scheduling Pro, capacity provided by shifts minus capacity consumed, and zones as groups of ZIP codes