Position BOMs — from a quote line to a parts list
Audience: developers & AI agents · Scope: how one quote position is resolved to a set of materials, and everything recorded about that attempt · Last reviewed: 2026-07-25
TL;DR — When a quote line is added or edited, the site tries to find its BOM by walking a fixed
resolution ladder: is it a custom line? does the configuration code exactly match an Infor item name?
an order-line code? can CPQ configure it? is there a partial match over 50%? The first branch that answers
wins, writes materials to #__configbox_external_user_materials, and stamps bom_* columns on the position
saying how it was answered. The whole thing runs after the HTTP response is flushed, and every
failure is swallowed — a broken BOM never breaks saving a quote line.
What it is & why it exists
Beta-Calco quotes lines that are configured on the website but manufactured in Infor. Someone downstream — costing, purchasing, the plant — needs to know which parts a quoted luminaire consumes. Infor can answer that, but only if you can tell it which item you mean, and the website's configuration code is not always an item Infor has ever heard of.
So resolution is best-effort and self-describing. A position always ends up with a bom_status, and when
the answer is approximate the position also records how approximate (bom_match_percentage,
bom_is_exact_match) so a human can judge whether to trust it. See the
shared concepts for the vocabulary used below.
How it fits together
addPosition / editPosition (controllers/bcmyquotes.php)
│
│ JSON response echoed
▼
fastcgi_finish_request() ← connection closed, user is done waiting
│
▼
startDeferredBomCalc() ──catches everything──► logs only
│
▼
updatePositionMaterials($positionId, $cartPositionDetails)
│
├─ 1. custom line? ───────────────────────────────► is_custom
├─ 2. item-name exact match (code, then regenerated) ► done / slash_method
├─ 3. order-line-code exact match (both codes) ────► done / slash_method
├─ 4. product has a CPQ ruleset? ─────────────────► done|error / cpq
├─ 5. best partial match > 50% ───────────────────► done / slash_method
└─ 6. otherwise ──────────────────────────────────► no_bom
| File | Role |
|---|---|
CUST/controllers/bcmyquotes.php:~1130,~1198 | the only two callers — fires the deferred calculation after addPosition / editPosition |
CUST/models/bcquotes.php → updatePositionMaterials() | the resolution ladder — the centrepiece |
CUST/models/bcquotes.php → getItemNameMatch() / getCoLineCodeMatch() | the two slash-method lookups (item names; known order-line codes) |
CUST/models/bcquotes.php → getMaterialsViaBomCache() | reads the flat BOM out of the cache |
CUST/models/bcquotes.php → getMaterialsViaCpq() / getItemsFromCpq() | the CPQ branch |
CUST/models/bcquotes.php → storePositionMaterials() | clears and rewrites this position's materials |
CUST/models/bcquotes.php → recalculatePositionBoms() | bulk re-run over a date/status window |
CUST/controllers/bcbomstatus.php + views/bcbomstatus/ | the per-position diagnostic screen (also embedded in emails and fail reports) |
CUST/models/admincpqbomfails.php | the failure register + its notification mail |
CUST/controllers/admincpq.php | bulkUpdatePosMaterials — the admin-triggered bulk recalculation |
cli/cb_pos_bom_recalculate.php | the CLI bulk recalculation |
cli/cb_pos_bom_send_report_notifications.php | weekday email about open fail reports |
The resolution ladder
Read in order; the first match returns.
| # | Condition | Result | bom_fetch_method |
|---|---|---|---|
| 1 | description_override or configuration_code_override set, product_id null, or is_custom = 1 | is_custom | NULL |
| 1b | the position has no selections | is_custom | NULL |
| 2 | the position's stored code is an Infor item name | done, 100%, exact | slash_method (item_name) |
| 2b | the regenerated code is an Infor item name | done, 100%, exact | slash_method (item_name) |
| 3 | the stored code is a known order-line code | done, 100%, exact | slash_method (co_line) |
| 3b | the regenerated code is a known order-line code | done, 100%, exact | slash_method (co_line) |
| 4 | the product has cpq_has_configuration | done or error | cpq |
| 5 | best partial match > 50% | done, that %, not exact | slash_method (winning source) |
| 6 | none of the above | no_bom | NULL |
Two details that matter:
- The code is regenerated and compared. Before matching, the current selections are re-rendered into a
configuration code (
ConfigboxModelBcselections::getCodeSegments()) and compared with the code stored on the position. A difference setsbom_configuration_changed = 1— meaning the product's option data has changed since the line was quoted. Both codes are then tried at each rung. - Step 4 returns unconditionally. The CPQ branch ends in
finally { return; }, so a product with a ruleset never falls through to step 5. A CPQ failure is anerror, not a partial match.
Partial matching
Both slash-method lookups degrade the same way: take the code, and while there is no hit, drop the last
/-segment and retry with LIKE 'prefix%'. The match percentage is matched segments ÷ total segments, and
a match of fewer than 3 segments scores 0 — a deliberate floor, since a two-segment prefix matches almost
anything. Order-line-code lookups additionally ignore the literal item name Standard Item.
Gotcha: the partial-match query interpolates the code fragment into a
LIKEwithout escaping (only the exact-match query is escaped). A configuration code containing%or_would behave as a wildcard. Configuration codes are generated from SKUs, so this is not reachable from normal data.
bom_status — the states
| Status | Meaning |
|---|---|
open | never calculated (the column default, and what a wipe resets to) |
processing | set on entry to updatePositionMaterials(); a position stuck here means the calculation died mid-flight |
done | materials were stored — read bom_fetch_method, bom_is_exact_match and bom_match_percentage to judge quality |
is_custom | deliberately has no BOM (custom line, overridden description/code, or no selections) |
no_bom | tried and found nothing good enough |
error | CPQ was tried and failed; bom_failure_keyword says which kind |
processingis not cleaned up by anything. Because the calculation runs post-response withset_time_limit(0)andignore_user_abort(true), a PHP worker killed mid-calculation leaves the rowprocessingforever. Re-running the position (edit it, or a bulk recalculation covering it) is the fix.
Deferred calculation
BOM resolution can take seconds — a CPQ round trip plus an Infor query. It is therefore run after the response:
echo ConfigboxJsonResponse::makeOne()->setSuccess(true)->toJson();
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
$quotesModel->startDeferredBomCalc($quotesModel, $cartPositionModel, $positionId, $positionDetails);
}
Three consequences worth knowing:
startDeferredBomCalc()catchesThrowableand only logs. A BOM failure can never surface to the user, and never fails the save. If a BOM is missing, the logs (custom_position_bom_calculation) are the only place to look.- It is skipped entirely if
fastcgi_finish_requestdoes not exist. The site ships a polyfill atsystem_overrides/fastcgi_finish_request.phpthat flushes and closes the session, so under a non-FPM SAPI the function exists but the work runs in-request — slower, but not skipped. - The calculation makes a throwaway cart position to re-derive the selections, and removes it afterwards.
Admin settings
This spoke has no settings of its own. The behaviour it depends on is configured elsewhere:
| Setting | Where | Effect here |
|---|---|---|
cpq_* (endpoint, instance, app name, profile, site id) | Custom Settings → Infor CPQ Integration | whether step 4 can run at all — see cpq.md |
cpq_fail_reporting_email | Custom Settings → Infor CPQ Integration | recipient for configuration-class failures; blank = no mail |
cpq_fail_reporting_email_errors | Custom Settings → Infor CPQ Integration | recipient for exception-class failures; blank = no mail |
cpq_has_configuration, cpq_ruleset_namespace, cpq_ruleset_name | product edit screen → Infor CPQ Integration | whether a product reaches step 4 |
| Infor DB credentials | Custom Settings → Infor DB | the cache read and every item lookup — see the Infor hub |
Data model
Materials, one row per part per position:
| Table | Column | Notes |
|---|---|---|
#__configbox_external_user_materials | position_id, item, pmt_code, qty, path | rewritten wholesale on each calculation (clearPositionMaterials() then insert). ON DELETE CASCADE from the position — added by migration 0.3.7.php |
The verdict, on the position row (#__configbox_external_user_positions):
| Column | Meaning | Migration |
|---|---|---|
bom_status | the state table above; VARCHAR(127), default open | 0.3.5.php |
bom_failure_keyword | configuration or exception (CPQ only) | 0.3.5.php |
bom_failure_message | the exception message | 0.3.5.php |
bom_parameters | JSON: the ruleset used and the option list values sent | 0.3.5.php |
bom_overrides | JSON: which question values were overridden on the way to CPQ | — |
bom_fetch_method | slash_method or cpq | 0.3.29.php |
bom_is_exact_match | '1'/'0'; for CPQ it means "no overrides were applied" | 0.3.29.php |
bom_match_percentage | DECIMAL(3,0) — slash method only | 0.3.29.php |
bom_slash_method_source | item_name or co_line | 0.3.29.php |
bom_configuration_changed | the stored code no longer matches the regenerated one | — |
bom_calc_date | when the attempt started | — |
Note the overload:
bom_is_exact_matchmeans "the code matched an item exactly" for the slash method, but "no value overrides were needed" for CPQ. Read it together withbom_fetch_method.
The failure register is #__configbox_external_cpq_bom_fails (model admincpqbomfails): failure keyword,
CPQ detail id, product, the position's modification date, a rendered HTML snapshot of the BOM-status screen,
and an operator-set status of open / fixed / unfixable plus notes_fix.
Control / data flow — bulk recalculation
Two front doors onto the same recalculatePositionBoms($filters):
# All positions on quotes created/modified in the window, restricted by current BOM status
php docroot/cli/cb_pos_bom_recalculate.php \
--start-date=2026-01-01 --end-date=2026-07-01 --status=no_bom --status=error
--start-date,--end-dateand--statusare all required;--statusacceptsall,open,processing,no_bom,error,is_customand may repeat.--redo=1callswipePositionBomData(), which wipes BOM data for every position in the database — not just the window you asked for. It is anUPDATEwith noWHEREplus a delete of all position materials. Do not pass it from cron.--wipe-existingis parsed but never used — it does nothing.- The run polls for up to 5 minutes if a cache refresh is in progress, then gives up.
- Before the loop, every custom line in the database is force-stamped
is_custom— also unscoped by the filters. - Positions are processed in batches of 200 with a 1-second pause between batches.
The admin equivalent is admincpq::bulkUpdatePosMaterials, taking the same three filters from the request.
Integrations & contracts
- Infor via the BOM cache (MSSQL) and CPQ (SOAP).
- Outbound: other systems can request a BOM for a configuration code over the BOM REST API, which reuses the CPQ branch of this logic.
- CloudWatch:
Test-BOM-Calculation—Count-Get-Items-From-Cpq,CPQ-Failure-Report-Added,CPQ-Failure-Report-Marked-Fixed/-Unfixable(the last two emitted byObserverBcCpqFailswhen an operator saves a report). Despite the name, this is a production namespace.
Deployment runbook (manual steps)
Position BOM calculation ships with the code and turns itself on as soon as its dependencies exist.
- Run the migrations (automatic on next page load) —
0.3.5,0.3.7,0.3.29add the columns and the materials table. See migrations. - Configure the Infor DB settings so the cache is readable, and the Infor CPQ Integration settings so step 4 works (cpq.md).
- Set the two fail-reporting email addresses (or leave blank to disable the mails).
- Make sure the BOM cache has been populated at least once, or every slash-method lookup misses.
- Add the cron entries — the fail-report mail, and (optionally, usually by hand) bulk recalculation. See scheduled jobs.
- Smoke test: add a line to a quote for a product you know is in Infor, wait a few seconds, then reload
and open the BOM status screen for that position. Expect
done/slash_method/ 100%.
Turning it off: there is no flag. Emptying cpq_endpoint_url disables the CPQ branch (positions fall to
error, not to step 5). Clearing both fail-reporting addresses silences the emails. Removing the cron entries
stops the scheduled work; the per-line calculation still runs on save.
Testing
No automated coverage — resolution needs a live Infor. In practice you verify by adding a line to a quote on
DDEV/staging and reading the BOM status screen plus the custom_position_bom_calculation log. The
e2e standard at testing/README.md applies to anything you add around it.
Gotchas & caveats
- Silent by design. Every failure path here logs and returns. Nothing tells the agent their line has no BOM; nothing retries. Absence of a BOM is only visible on the status screen or in the register.
errornever falls back. A product with a CPQ ruleset that fails is not then attempted against the slash method, even when a decent partial match exists.- A stored code that no longer regenerates (
bom_configuration_changed = 1) is a strong hint the product's answer SKUs changed after quoting — the BOM may be for a different build than the customer was quoted. - The fail-report email counts every report with that keyword, not just
openones —notifyOnFailReports()filters onfailure_keywordalone. Marking a reportfixeddoes not reduce tomorrow's count; only deleting it does (a listing action gated oncom_cbcustomization.core.delete_bom_reports). - Materials are not versioned. Recalculating a position overwrites its materials with no history, so a quote's BOM can change under it long after the customer saw the price.
- BOM data is admin-only.
BcHelper::canSeeBomData()iscom_configbox.core.manage.
Possible follow-ups
- Clear stale
processingrows (age-based) so a killed worker is self-healing. - Let a failed CPQ attempt fall through to the partial-match rung instead of stopping at
error. - Scope
wipePositionBomData()to the filters the caller actually passed, and remove or implement--wipe-existing. - Filter the fail-report notification on
status = 'open'.
Related docs
- Hub: BOM & CPQ · spokes: bom-cache.md · cpq.md · rest-api.md
- Quotes — what a position is · Infor — the access paths
- Scheduled jobs · known issues:
docs/_known-issues.md