Workflow Category Routing Developer Guide - edmozley/freeitsm GitHub Wiki
How a workflow routes tickets by category (#298): the ticket.category_id condition on every ticket trigger, the ticket.category_changed trigger, subcategory matching and the setting behind it. For what it does for the person building a workflow, read Workflows - Routing tickets by category first; for the engine as a whole, Workflow engine - Developer Guide.
Three small pieces, all in or next to WorkflowEngine:
-
The field everywhere.
ticket.category_idis in the full ticket field set, so the editor offers it on everyticket.*trigger. Rather than teach the dozen places that build ticket events to add it,dispatch()fills it in with one query when an event did not send it. -
A new trigger.
TicketsService::updateTicket()firesticket.category_changedwhen the category really changes - because email tickets arrive with no category and get one at triage, soticket.createdalone never sees it. -
Subcategories. When a condition on
ticket.category_idusesin/not_in(what the editor's checkbox list saves), the ids are widened to include every category beneath them - unless the install settingworkflow_category_matchisexact.
No new table and no new column: the setting is a system_settings row.
| File | What changed |
|---|---|
workflow/includes/engine.php |
the trigger, the field, withTicketCategory(), categoryMatchIncludesSubcategories(), categoryIdsWithDescendants(), the condition loop |
includes/services/tickets.php |
updateTicket() remembers a category change and dispatches ticket.category_changed; the update payload and eventTicket() carry category_id
|
includes/ticket_events.php |
ticketDispatchCreated() - the ticket.created payload carries category_id
|
tickets/settings/index.php, tickets/settings/manifest.php
|
the Categories in workflows select on the Categories tab, saved on change; the key registered to that tab |
workflow/editor.php, assets/js/workflow-editor.js
|
window.WF_CATEGORY_TREE and the tip under a category condition (categoryMatchTip()) |
lang/*/tickets.php, lang/*/workflow.php
|
tickets.settings.categories.wf_match_*, workflow.editor.category_tip_*
|
tests/workflow-category-routing.php |
18 checks, real workflows fired by real tickets |
availableTriggers() gains one entry:
// GH #298. Email tickets arrive with no category and get one at triage,
// so "route by category" needs this as well as ticket.created.
'ticket.category_changed' => 'A ticket\'s category changes',availableFields() adds the field to $fullTicket, which every ticket.* trigger merges in, and gives the new trigger its two extras:
'ticket.requester_email',
// GH #298. Filled in by withTicketCategory() for every ticket event, so
// "a ticket in Hardware" is a condition on any ticket trigger.
'ticket.category_id',
];
...
'ticket.category_changed' => array_merge($fullTicket, ['old_category_id', 'new_category_id']),ticket.category_id was already in FIELD_LOOKUP_TABLES (for the Set category action, #209), and availableValuesForField() hands it to categoryLookupValues(), which labels each category with its full path - so the condition's checkbox list reads Hardware โ Laptop, not a bare Laptop. Since 5.1.0 old_category_id / new_category_id are in FIELD_LOOKUP_TABLES too (they were plain number boxes), and CATEGORY_FIELDS names all three: availableValuesForField() gives each the path-labelled list, evaluateConditions() applies the subcategory rule to each, and enrichWithLookupNames() adds {{old_category_name}} / {{new_category_name}} for free. The editor's tip (categoryMatchTip()) shows for all three.
Ticket events are built in many places (replies, notes, SLA, assignment, the REST API...). Instead of adding category_id to each, dispatch() calls one helper just after the module-off check:
if (self::moduleOffFor($conn, $event) !== null) return;
$payload = self::withTicketCategory($conn, $event, $payload); // GH #298 private static function withTicketCategory(PDO $conn, string $event, array $payload): array
{
if (!str_starts_with($event, 'ticket.') || !is_array($payload['ticket'] ?? null)) return $payload;
if (array_key_exists('category_id', $payload['ticket'])) return $payload;
$id = (int)($payload['ticket']['id'] ?? 0);
if ($id <= 0) return $payload;
try {
$s = $conn->prepare("SELECT category_id FROM tickets WHERE id = ?");
$s->execute([$id]);
$v = $s->fetchColumn();
$payload['ticket']['category_id'] = ($v === false || $v === null) ? null : (int)$v;
} catch (Throwable) { /* before Database Verify: no category, condition does not match */ }
return $payload;
}array_key_exists, not isset. A payload that says category_id => null is believed - the ticket has no category - and costs no query. The two hottest paths now send it themselves, so they never query:
includes/ticket_events.php, ticketDispatchCreated():
// GH #298: route a new ticket by its category.
'category_id' => $t['category_id'] !== null ? (int)$t['category_id'] : null,includes/services/tickets.php, the update payload (re-read after the save) and eventTicket():
'category_id' => isset($r['category_id']) ? (int)$r['category_id'] : null, // GH #298Only ticket.* events with a ticket array and an id are touched; a task.* event carrying task.ticket_id (#274) is not, on purpose - it has no top-level ticket.
TicketsService::updateTicket() already compares each lookup field with its old value and skips an unchanged one. The category branch now remembers the change:
$categoryChange = null;
foreach ([
'category_id' => ['Category', 'category'],
...
if ($newId === $oldId) {
continue;
}
if ($field === 'category_id') $categoryChange = [$oldId, $newId]; // GH #298: ticket.category_changedand, after the save, with the other change events:
// GH #298. Email tickets get their category at triage, so routing by
// category needs to hear about the change, not only about creation.
if ($categoryChange !== null) {
WorkflowEngine::dispatch('ticket.category_changed', [
'ticket' => $payload, 'old_category_id' => $categoryChange[0], 'new_category_id' => $categoryChange[1],
]);
}So it fires for set, change and clear (new_category_id null), from any caller of updateTicket() - the reading pane, the workspace, the REST API and the Set category workflow action - and not when the same value is saved again ($newId === $oldId continues first).
A type change that clears the category fires it too (5.1.0). The clear is made by the block just after this loop ("A category may be tied to one ticket type..."), which adds its own category_id = NULL update. Until 5.1.0 it did not set $categoryChange, so the event did not fire and routing on "left Hardware" missed it. It now sets $categoryChange = [(int) $catId, null] for category_id (not closure_category_id). An explicit category_id in the same request still wins - that block skips a field the caller set. Write-up.
workflow_category_match in system_settings: 'tree' or 'exact'. Never saved means tree - subcategories included - and so does any read error:
public static function categoryMatchIncludesSubcategories(): bool
{
try {
$s = connectToDatabase()->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'workflow_category_match'");
$s->execute();
return $s->fetchColumn() !== 'exact';
} catch (Throwable) {
return true;
}
}It is install-wide (not per company) and registered to the Categories tab in tickets/settings/manifest.php, so the settings save endpoint accepts it from that tab:
'ticket_resolution_code_enabled',
'workflow_category_match', // GH #298: does a workflow category condition match subcategoriesIn the condition loop, only ticket.category_id with in / not_in:
$tested = $value;
$withSubs = false;
if ($field === 'ticket.category_id' && in_array($op, ['in', 'not_in'], true) && self::categoryMatchIncludesSubcategories()) {
$tested = self::categoryIdsWithDescendants($value);
$withSubs = true;
}
$passed = self::evaluateOperator($op, $actual, $tested);
...
if ($withSubs) $stepLog[array_key_last($stepLog)]['includes_subcategories'] = true;not_in is widened too, so "category is not Hardware" also excludes Hardware โ Laptop - the mirror of in. equals / not_equals (only reachable by hand-written JSON or the REST API, since lookups offer is / is not) stay exact.
private static function categoryIdsWithDescendants($value): array
{
$ids = is_array($value) ? $value : (is_string($value) ? explode(',', $value) : [$value]);
$ids = array_values(array_unique(array_filter(array_map(fn($x) => (int)trim((string)$x), $ids))));
if (!$ids) return [];
try {
$conn = connectToDatabase();
$all = $ids;
$frontier = $ids;
for ($round = 0; $frontier && $round < 5; $round++) {
$st = $conn->prepare('SELECT id FROM ticket_categories WHERE parent_id IN (' . implode(',', array_fill(0, count($frontier), '?')) . ')');
$st->execute($frontier);
$frontier = array_values(array_diff(array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)), $all));
$all = array_merge($all, $frontier);
}
return array_map('strval', $all);
} catch (Throwable) {
return array_map('strval', $ids);
}
}-
Breadth-first by level, one query per level. Categories are at most three deep, so two rounds find everything; the cap of 5 and the
array_diffagainst$allguard against a badparent_idloop. -
Retired categories are included. There is no
is_activefilter: a ticket still in a retired subcategory should still route with its parent. -
Strings out, because the editor saves the ticked ids as strings and
evaluateOperator()compares loosely against the payload's int. - On a database error it falls back to the ids as ticked - an exact match, never a broader one.
The setting is read once per category condition evaluated (one small query); nothing is cached across requests, so a change applies to the next event.
tickets/settings/index.php, Categories tab, under the field switches:
<h4 class="tc-sub4"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_heading')); ?></h4>
<div class="form-group" style="max-width: 520px;">
<label for="tcWorkflowMatch"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_label')); ?></label>
<select id="tcWorkflowMatch">
<option value="tree"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_tree')); ?></option>
<option value="exact"><?php echo htmlspecialchars(t('tickets.settings.categories.wf_match_exact')); ?></option>
</select>It loads from get_system_settings.php and saves on change through save_system_settings.php (anything but exact is written as tree), showing the Saved toast - no Save button involved, like the portal field choices.
workflow/editor.php tells the editor which way it is set:
window.WF_CATEGORY_TREE = <?php echo WorkflowEngine::categoryMatchIncludesSubcategories() ? 'true' : 'false'; ?>;and assets/js/workflow-editor.js (?v=25) appends a tip under the checkbox list when the condition's field is ticket.category_id:
function categoryMatchTip() {
const t = k => window.t('workflow.editor.' + k);
return '<div style="margin-top:8px; padding:7px 10px; border-radius:5px; font-size:12px; line-height:1.5; background:var(--info-bg, #eef6ff); color:var(--text, #333);">' +
escAttr(t(window.WF_CATEGORY_TREE ? 'category_tip_tree' : 'category_tip_exact')) + ' ' +
'<a href="../tickets/settings/#categories" target="_blank" rel="noopener">' + escAttr(t('category_tip_link')) + '</a></div>';
}The flag is read when the editor page loads; change the setting in another tab and the tip is right after a reload.
FREEITSM_TEST_DATABASE=<the throwaway database config.php uses> php tests/workflow-category-routing.php
18 checks - see Developer Tests - Security & access. It makes ZZ categories (Hardware with a Laptop child), real active ZZ workflows with an Add a note action, and real tickets through TicketsService, then checks the notes:
- the field is offered on every ticket trigger; the trigger exists with its two fields; the picker labels the subcategory with its path;
- never saved = subcategories included; a Hardware workflow runs for a Laptop ticket, not for another category or none;
- set to exact: the Laptop ticket no longer matches, Hardware still does; the run history carries
"includes_subcategories":truewhen it applied; - setting a category fires the trigger and routes it, with
old_category_idnull andnew_category_idthe new one; saving the same category again fires nothing; - a note on a Laptop ticket - an event that never carried a category - matches, because the engine looked it up;
-
(5.1.0)
old_category_idandnew_category_idare lookups whose list includes the subcategory; a type change that clears a Laptop category fires the trigger withold_category_idLaptop andnew_category_idnull, and a workflow on old category is Hardware catches it. 24 checks; against 5.0.0 those five fail.
Other active workflows on the three triggers used are paused for the run and put back; every ZZ row is removed in finally and the setting restored.