Skip to main content

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
FileRole
CUST/controllers/bcmyquotes.php:~1130,~1198the only two callers — fires the deferred calculation after addPosition / editPosition
CUST/models/bcquotes.phpupdatePositionMaterials()the resolution ladder — the centrepiece
CUST/models/bcquotes.phpgetItemNameMatch() / getCoLineCodeMatch()the two slash-method lookups (item names; known order-line codes)
CUST/models/bcquotes.phpgetMaterialsViaBomCache()reads the flat BOM out of the cache
CUST/models/bcquotes.phpgetMaterialsViaCpq() / getItemsFromCpq()the CPQ branch
CUST/models/bcquotes.phpstorePositionMaterials()clears and rewrites this position's materials
CUST/models/bcquotes.phprecalculatePositionBoms()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.phpthe failure register + its notification mail
CUST/controllers/admincpq.phpbulkUpdatePosMaterials — the admin-triggered bulk recalculation
cli/cb_pos_bom_recalculate.phpthe CLI bulk recalculation
cli/cb_pos_bom_send_report_notifications.phpweekday email about open fail reports

The resolution ladder

Read in order; the first match returns.

#ConditionResultbom_fetch_method
1description_override or configuration_code_override set, product_id null, or is_custom = 1is_customNULL
1bthe position has no selectionsis_customNULL
2the position's stored code is an Infor item namedone, 100%, exactslash_method (item_name)
2bthe regenerated code is an Infor item namedone, 100%, exactslash_method (item_name)
3the stored code is a known order-line codedone, 100%, exactslash_method (co_line)
3bthe regenerated code is a known order-line codedone, 100%, exactslash_method (co_line)
4the product has cpq_has_configurationdone or errorcpq
5best partial match > 50%done, that %, not exactslash_method (winning source)
6none of the aboveno_bomNULL

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 sets bom_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 an error, 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 LIKE without 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

StatusMeaning
opennever calculated (the column default, and what a wipe resets to)
processingset on entry to updatePositionMaterials(); a position stuck here means the calculation died mid-flight
donematerials were stored — read bom_fetch_method, bom_is_exact_match and bom_match_percentage to judge quality
is_customdeliberately has no BOM (custom line, overridden description/code, or no selections)
no_bomtried and found nothing good enough
errorCPQ was tried and failed; bom_failure_keyword says which kind

processing is not cleaned up by anything. Because the calculation runs post-response with set_time_limit(0) and ignore_user_abort(true), a PHP worker killed mid-calculation leaves the row processing forever. 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:

  1. startDeferredBomCalc() catches Throwable and 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.
  2. It is skipped entirely if fastcgi_finish_request does not exist. The site ships a polyfill at system_overrides/fastcgi_finish_request.php that flushes and closes the session, so under a non-FPM SAPI the function exists but the work runs in-request — slower, but not skipped.
  3. 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:

SettingWhereEffect here
cpq_* (endpoint, instance, app name, profile, site id)Custom Settings → Infor CPQ Integrationwhether step 4 can run at all — see cpq.md
cpq_fail_reporting_emailCustom Settings → Infor CPQ Integrationrecipient for configuration-class failures; blank = no mail
cpq_fail_reporting_email_errorsCustom Settings → Infor CPQ Integrationrecipient for exception-class failures; blank = no mail
cpq_has_configuration, cpq_ruleset_namespace, cpq_ruleset_nameproduct edit screen → Infor CPQ Integrationwhether a product reaches step 4
Infor DB credentialsCustom Settings → Infor DBthe cache read and every item lookup — see the Infor hub

Data model

Materials, one row per part per position:

TableColumnNotes
#__configbox_external_user_materialsposition_id, item, pmt_code, qty, pathrewritten 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):

ColumnMeaningMigration
bom_statusthe state table above; VARCHAR(127), default open0.3.5.php
bom_failure_keywordconfiguration or exception (CPQ only)0.3.5.php
bom_failure_messagethe exception message0.3.5.php
bom_parametersJSON: the ruleset used and the option list values sent0.3.5.php
bom_overridesJSON: which question values were overridden on the way to CPQ
bom_fetch_methodslash_method or cpq0.3.29.php
bom_is_exact_match'1'/'0'; for CPQ it means "no overrides were applied"0.3.29.php
bom_match_percentageDECIMAL(3,0) — slash method only0.3.29.php
bom_slash_method_sourceitem_name or co_line0.3.29.php
bom_configuration_changedthe stored code no longer matches the regenerated one
bom_calc_datewhen the attempt started

Note the overload: bom_is_exact_match means "the code matched an item exactly" for the slash method, but "no value overrides were needed" for CPQ. Read it together with bom_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-date and --status are all required; --status accepts all, open, processing, no_bom, error, is_custom and may repeat.
  • --redo=1 calls wipePositionBomData(), which wipes BOM data for every position in the database — not just the window you asked for. It is an UPDATE with no WHERE plus a delete of all position materials. Do not pass it from cron.
  • --wipe-existing is 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-CalculationCount-Get-Items-From-Cpq, CPQ-Failure-Report-Added, CPQ-Failure-Report-Marked-Fixed / -Unfixable (the last two emitted by ObserverBcCpqFails when 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.

  1. Run the migrations (automatic on next page load) — 0.3.5, 0.3.7, 0.3.29 add the columns and the materials table. See migrations.
  2. Configure the Infor DB settings so the cache is readable, and the Infor CPQ Integration settings so step 4 works (cpq.md).
  3. Set the two fail-reporting email addresses (or leave blank to disable the mails).
  4. Make sure the BOM cache has been populated at least once, or every slash-method lookup misses.
  5. Add the cron entries — the fail-report mail, and (optionally, usually by hand) bulk recalculation. See scheduled jobs.
  6. 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.
  • error never 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 open ones — notifyOnFailReports() filters on failure_keyword alone. Marking a report fixed does not reduce tomorrow's count; only deleting it does (a listing action gated on com_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() is com_configbox.core.manage.

Possible follow-ups

  • Clear stale processing rows (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'.