The job was booked and the crew was never told

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.

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 after the previous stage. Winning a New Construction bid produced a Draft Order and an install WorkOrder, 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

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.

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

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, 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 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

<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:

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:

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.

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.

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.

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.

Filed under

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.

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.

Working on something like this?

I can tell you what it takes.

If you run a home services business and this looks like a problem you have, book a call and we will talk through it.