---
title: "The job was booked and the crew was never told"
description: "Closed Won produced an Order and an install Work Order, and the dispatch board stayed empty, because a Work Order is not a thing anyone can schedule. Creating the ServiceAppointment in Apex rather than switching on ShouldAutoCreateSvcAppt, and the silent SaveResult that cost an afternoon on the way."
source: "https://noconaconsulting.com/blog/service-appointment-from-won-opportunity/"
---

# The job was booked and the crew was never told

_Build log · Doorvana · Garage door manufacturing & service_

Closed Won produced an Order and an install Work Order, and the dispatch board stayed empty, because a Work Order is not a thing anyone can schedule. Creating the ServiceAppointment in Apex rather than switching on ShouldAutoCreateSvcAppt, and the silent SaveResult that cost an afternoon on the way.

Nocona Consulting · October 14, 2026 · 10 min read

A builder signs. The office sees the order, the install job is sitting there on the account with its line items, and every record page reads _booked_. Then the dispatcher opens the board for that week and there is nothing on it — not a job that needs scheduling, not a warning, just the week as it was. A signed contract nobody can put on a calendar is only half booked.

That was the state at [Doorvana](https://noconaconsulting.com/work/) after the previous stage. Winning a New Construction bid [produced a Draft `Order` and an install `WorkOrder`](https://noconaconsulting.com/blog/booking-a-won-bid-order-and-install/), which is most of the work and none of the point.

## A Work Order is not a schedulable thing

The Field Service dispatcher console schedules **`ServiceAppointment`** records. It does not schedule `WorkOrder` records. That distinction is obvious in the data model and very easy to lose while building, because the Work Order is the record with the customer, the address, the line items and the money on it — it looks like the job, and the thing dispatch actually moves around is a child record it does not have.

The sandbox said so plainly. The install Work Order existed, its line items existed, and a query for appointments under it returned nothing. So did the next question:

anonymous Apex — why no appointment existed

```apex
for (WorkType wt : [SELECT Name, ShouldAutoCreateSvcAppt, EstimatedDuration, DurationType
                    FROM WorkType]) {
    System.debug(wt.Name + ' auto-create=' + wt.ShouldAutoCreateSvcAppt);
}
// Diagnosis  auto-create=false
// Repair     auto-create=false
// Install    auto-create=false
```

`WorkType.ShouldAutoCreateSvcAppt` is the platform’s own answer to this: tick it, and Field Service creates an appointment whenever a Work Order or Work Order Line Item is created with that Work Type. All three Work Types had it `false` — the default, and nothing in the earlier setup had changed it.

## Why the checkbox stayed off

The obvious fix is to tick it. The reason not to is that this org already creates one appointment explicitly — in a flow, not in Apex.

`Book_Service_Call` — the flow behind booking a free diagnostic visit from the Account — creates the Diagnosis `ServiceAppointment` itself with a Create Records element, because it needs to set the window and the address at the moment of booking rather than accept the defaults. Turning on auto-create for the Diagnosis Work Type would not replace that element; it would run _alongside_ it, and every service call would land on the dispatch board twice.

Rule of thumb

`ShouldAutoCreateSvcAppt` and creating the appointment yourself — Flow or Apex — are an either-or per Work Type, and the failure mode of getting it wrong is duplicate appointments rather than an error. Pick one mechanism per Work Type, and pick the same one across the org unless you enjoy explaining to a dispatcher why some jobs appear twice.

So the install appointment is created explicitly too — in Apex this time, in `OpportunityBookingService`, because that is where the Order and Work Order are already built — for the same reason.

## What the appointment needs to be useful

An appointment can be inserted with almost nothing on it. It will also be useless, in ways that do not announce themselves.

force-app/main/default/classes/OpportunityBookingService.cls

```apex
ServiceAppointment buildInstallAppointment(
    WorkType installType, Id territoryId, Datetime windowStart, Integer installWindowDays
) {
    Schema.Location lot = opportunity.Location__r;
    Datetime dueDate = windowStart.addDays(installWindowDays);
    if (lot?.PossessionDate != null && lot.PossessionDate > windowStart.date()) {
        dueDate = Datetime.newInstance(lot.PossessionDate, Time.newInstance(17, 0, 0, 0));
    }
    return new ServiceAppointment(
        ParentRecordId = installWorkOrder.Id,
        Subject = installWorkOrder.Subject,
        EarliestStartTime = windowStart,
        DueDate = dueDate,
        ServiceTerritoryId = territoryId,
        Duration = installType?.EstimatedDuration,
        DurationType = installType?.DurationType,
        Street = lot?.Site_Street__c,
        City = lot?.Site_City__c,
        StateCode = lot?.Site_State__c,
        PostalCode = lot?.Site_Postal_Code__c,
        CountryCode = 'US'
    );
}
```

Four of those fields are load-bearing in a way that is not obvious from the object reference.

**`Duration` and `DurationType` come from the Work Type**, not from a constant. The Install Work Type carries `EstimatedDuration = 4` and `DurationType = Hours`, so changing how long an install is assumed to take is an admin editing one record, not a developer editing a class. An appointment with a null `Duration` schedules as a zero-length block and quietly stops representing a real day’s work.

**The address is copied onto the appointment.** The Work Order has a `LocationId`, the Location has the lot’s address, and none of that reaches the scheduler: it reads the appointment’s own compound `Address`, populated through `Street`, `City`, `StateCode`, `PostalCode` and `CountryCode`. Note `StateCode` rather than `State` — [the same distinction that quietly breaks every address on this org](https://noconaconsulting.com/blog/lead-state-vs-statecode-restricted-picklist/), and `Location.Site_State__c` is already a code-keyed restricted picklist, so it drops straight in.

**`ParentRecordId` is the Work Order**, and it is polymorphic — it would equally accept a `WorkOrderLineItem`. One install is one visit to one lot, so it hangs off the job rather than a line, which is also what [the completion guard](https://noconaconsulting.com/blog/work-order-completion-guard-open-service-appointment/) expects to find.

## The due date is the interesting decision

Everything above is mechanics. The due date is the part that required knowing something about garage doors.

An install is not “sometime in the next month”. It has to be finished before the homeowner takes possession of the house, because a house without a garage door does not close. The lot already carries that date: `Location.PossessionDate`, a standard field on the standard `Location` object that this model was already using for the property.

_The window opens at booking. It closes on the possession date when that is still ahead, and on a configurable number of days from booking when it is not — a lot with no date, or one already past._

The fallback lives in a Custom Metadata Type rather than in Apex:

force-app/main/default/objects/Billing\_Setting\_\_mdt/fields/Install\_Window\_Days\_\_c.field-meta.xml

```xml
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
    <fullName>Install_Window_Days__c</fullName>
    <description>Days from booking to the Due Date of a won bid's install Service
        Appointment, used when the lot has no future Possession Date. A placeholder
        until the client confirms how far ahead installs are booked.</description>
    <fieldManageability>DeveloperControlled</fieldManageability>
    <label>Install Window Days</label>
    <precision>3</precision>
    <scale>0</scale>
    <type>Number</type>
</CustomField>
```

It is set to 30, and 30 is a guess. That is the honest reason it is a Custom Metadata Type: the number is going to be wrong, and when the client says what it actually is, changing it should be an edit rather than a deployment. A guess in a class is a guess that needs a release to correct, which is how guesses become permanent.

## What happens when the appointment fails

Booking already writes its problems to `Opportunity.Booking_Error__c`, and the appointment joins that scheme rather than getting its own.

The rule is that the appointment is the least important of the three records. The `Order` is what the customer agreed to buy; the `WorkOrder` is the job that has to be done; the `ServiceAppointment` is a row on a calendar that a dispatcher can recreate in under a minute. If the appointment insert fails, the other two stand and the Opportunity says so:

```plaintext
Order and install Work Order created, but the install Service Appointment was not:
REQUIRED_FIELD_MISSING Required fields are missing: [ServiceTerritoryId]
```

Rolling back a correct Order because a calendar row did not save would be destroying good data to tidy up a smaller problem.

## The afternoon that was not about any of this

The one genuinely expensive hour of this build had nothing to do with appointments.

A probe in the sandbox, checking whether the lead conversion field mapping was in place, came back with:

```plaintext
valid leadId is required: [leadId] — INVALID_CROSS_REFERENCE_KEY
```

Which reads as a conversion problem, and was treated as one for a while. It was not. Two lines earlier the `Lead` insert had failed on `REQUIRED_FIELD_MISSING: Required fields are missing: [Lead_Channel__c]`, and it had failed _quietly_, because the probe used `Database.insert(record, dmlOptions)` — an overload that takes no `allOrNone` argument, and with `DMLOptions.optAllOrNone` left at its default of `false` permits partial success, and returns a `Database.SaveResult` instead of throwing. Nobody read the result. The conversion was then handed a null Id, and complained about the only thing it could see.

An unread SaveResult is the most obedient bug in Apex

A `Database` DML that takes a `DMLOptions` will not throw unless `optAllOrNone` is set to `true` on those options — and it defaults to `false`. Swapping a plain `insert` statement for it — commonly [to get the duplicate-rule header across](https://noconaconsulting.com/blog/duplicate-rule-header-setoptions/) — trades a loud failure for a silent one unless every `SaveResult` is checked. The error you eventually see will be somewhere else entirely, and it will be about innocent code.

## A correction to the project’s own notes

The notes listed the `Lead.Customer_Type__c` → `Account.Customer_Type__c` lead conversion field mapping as still manual and unconfirmed. That is now wrong for the sandbox: a Builder lead converted there produced an Account with `Customer_Type__c = Builder`, and the conversion was rolled back afterwards. The mapping is in place. **Production still needs it**, and that remains a separate claim requiring separate evidence — the distinction this project [learned the hard way](https://noconaconsulting.com/blog/built-does-not-mean-deployed/).

Worth recording how that was settled, because the obvious route failed: retrieving `LeadConvertSettings` through the Metadata API hung for more than ten minutes with no output and was abandoned. Converting one fabricated lead and rolling it back took under a minute and proved more. When a metadata retrieve is fighting you, ask whether the behaviour itself is cheaper to observe than the configuration that produces it.

Update: the mapping was never a manual step

The project’s notes had described lead conversion field mapping as a manual Setup step. A later retrieve of `LeadConvertSettings` did complete, and returned the full `objectMapping` for Account and Opportunity — ordinary deployable metadata that had simply never been brought into source. It is in source now. The hung retrieve above was a bad run, not a sign the component is unreachable; converting a test lead is still a fast check of behaviour, but the configuration belongs in git.

## What the tests pin down

Deployed to the sandbox with `RunSpecifiedTests`: 26 of 26 passing across `OpportunityBookingServiceTest` and `WorkOrderCompletionGuardTest`, including a 251-bid bulk win asserting 251 appointments — one per install, past the 200-record boundary where a loop that looked fine starts failing.

Then the part tests cannot do. A fabricated bid won through anonymous Apex produced an install Work Order carrying the Install Work Type and the single active Service Territory, and one appointment under it: four hours, due on the lot’s possession date at 17:00 Central, address copied from the lot. That record appearing on the dispatch board is the only evidence that actually answers the question this post opened with.

## Practical summary

-   The dispatch console schedules `ServiceAppointment`, not `WorkOrder`. A booking automation that stops at the Work Order is not finished, and nothing will tell you.
-   `WorkType.ShouldAutoCreateSvcAppt` is `false` by default. Choose it _or_ creating the appointment in Apex, per Work Type — running both produces duplicates rather than an error.
-   Take `Duration` and `DurationType` from the Work Type so the estimate is admin-editable.
-   Copy the address onto the appointment’s own compound `Address`. The scheduler does not follow `ParentRecordId` to find one, and `StateCode` is the field that takes `TN`.
-   Derive `DueDate` from the date the work is actually needed — `Location.PossessionDate` here — and keep the fallback window in a Custom Metadata Type, because the first value is a guess.
-   Let the appointment fail without taking the Order with it, and write what did and did not get created somewhere a person will look.
-   A `Database` DML that takes a `DMLOptions` returns `SaveResult` and does not throw unless `optAllOrNone = true` is set on the options. Set it, or read every result, or debug the wrong error later.

-   Field Service
-   Service Appointment
-   Work Type
-   Apex
-   Custom Metadata Types
-   Scheduling

Disclosure: Doorvana's owner is also a co-owner of Nocona Consulting. This is a real engagement between two separate companies, but not an arm's-length one — and not an independent reference. [More on the work page](https://noconaconsulting.com/work/).

## FAQ

### Does a Salesforce Work Order appear on the Field Service dispatch console on its own?

No. The dispatcher console schedules ServiceAppointment records, not Work Orders. A Work Order with no appointment under it is invisible to dispatch no matter how complete it looks on the record page, which makes a booking automation that stops at the Work Order look finished while the crew is never told. The appointment is the schedulable unit and something has to create it.

### What is ShouldAutoCreateSvcAppt on the Salesforce Work Type object?

It is the checkbox that makes Field Service create a ServiceAppointment automatically whenever a Work Order or Work Order Line Item is created with that Work Type. It is false by default, so a Work Type that was set up through the data import or by hand will not produce appointments until someone turns it on. It is an either-or with creating appointments yourself in Flow or Apex: leaving it on while code also inserts one produces two appointments for one visit.

### How should the Due Date on a Service Appointment be set for a construction install?

From the date the work actually has to be finished by, not from a fixed offset. On a new construction lot that date is the possession date, so the appointment's DueDate is the possession date at end of business when it is still in the future, and a configurable number of days from booking when it is not. Putting that number in a Custom Metadata Type rather than in Apex means the scheduling window changes without a deployment.

### Why copy the address onto a Service Appointment when the Work Order already has one?

Because the scheduler reads the appointment's own compound Address when it optimises routes and travel time, rather than following ParentRecordId to the Work Order or the Location. An appointment with an empty address still schedules; it just schedules as though the work were nowhere, which distorts travel time for every other appointment in the territory that day.

### Should a failure to create a Service Appointment roll back the Order it belongs to?

Usually not. The Order and the Work Order are the record of what the customer agreed and what was sold, and they are correct whether or not an appointment exists. Deleting them because dispatch is missing a row destroys good data to fix a smaller problem. Recording the failure on the Opportunity in an error field, so a person can see exactly what was created and what was not, keeps the partial result honest and recoverable.
