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 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, notWorkOrder. A booking automation that stops at the Work Order is not finished, and nothing will tell you. WorkType.ShouldAutoCreateSvcApptisfalseby default. Choose it or creating the appointment in Apex, per Work Type — running both produces duplicates rather than an error.- Take
DurationandDurationTypefrom the Work Type so the estimate is admin-editable. - Copy the address onto the appointment’s own compound
Address. The scheduler does not followParentRecordIdto find one, andStateCodeis the field that takesTN. - Derive
DueDatefrom the date the work is actually needed —Location.PossessionDatehere — 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
DatabaseDML that takes aDMLOptionsreturnsSaveResultand does not throw unlessoptAllOrNone = trueis set on the options. Set it, or read every result, or debug the wrong error later.