Infor CPQ — configuring a part that does not exist yet
Audience: developers & AI agents · Scope: the CPQ branch of BOM resolution — the wire contract, the product/question mapping, and its failure modes · Last reviewed: 2026-07-25
TL;DR — CPQ (Configure-Price-Quote) is Infor's rule engine: hand it a ruleset and a set of option-list values and it configures a part and returns its manufacturing data, even for a combination that has never been built. It is the fallback when the slash method finds no matching item. Note the surprise: CPQ is a SOAP/WSDL service with its own endpoint and settings — it is neither the IDO REST API nor the MSSQL connection described in the Infor hub. It is a third access path.
What it is & why it exists
Beta-Calco's configurator lets a customer combine options in ways that no Infor item covers. The slash method can only find BOMs for builds Infor already knows. CPQ closes that gap: it evaluates the same rules the plant uses, produces the component list for the requested configuration, and hands it back.
The trade-off is cost and fragility. A CPQ call is a two-step SOAP round trip against a live rule engine, and it fails whenever the website's answer codes and the CPQ ruleset's option lists have drifted apart — which is exactly what the failure register exists to surface.
How it fits together
configuration code segments (from the quote line's selections)
│
▼
getRapidOptions() ← per question: is it a CPQ question?
│ apply value overrides, collect them
▼
RapidOption[] = [{VariableName: "Luminaire", ValueExpression: "PD4"}, …]
│
▼
┌──────────────────────────────────────────────┐
│ nusoap_client(cpq_endpoint_url, 'wsdl') │
│ │
│ 1. Configure(…) → Result must be │
│ "Success" │
│ 2. LoadMfgData(…) → BomComponentDto[] │
└──────────────────┬───────────────────────────┘
│ Part Number + Quantity per component
▼
BcCsiItemMaterialsRequest[] ("build me these, this many")
│
▼
getMaterialsForItems() → the SAME 5-level MSSQL explosion
│ used by the cache
▼
flat BOM for the position
| File | Role |
|---|---|
CUST/models/bcquotes.php → getItemsFromCpq() | the SOAP call — builds the payload, both calls, parses BomComponentDto |
CUST/models/bcquotes.php → getRapidOptions() | maps questions to CPQ option-list values and applies overrides |
CUST/models/bcquotes.php → getMaterialsViaCpq() | glue: CPQ → item requests → explosion |
CUST/models/bcquotes.php → getCpqMaterialsFromCode() | the same thing starting from a bare configuration code (used by the REST API) |
CUST/models/csimaterials.php | the explosion of whatever CPQ returns |
system_overrides/BcCpqConfigurationException.php | carries the ruleset, the options sent, the overrides applied and the CPQ detail id |
system_overrides/ObserverBcCpqFails.php | CloudWatch counters when an operator marks a fail report fixed/unfixable |
CUST/controllers/adminbccpqbom.php + views/adminbccpqbom/ | the CPQ Flat BOM Calculator screen |
libs/vendor (nusoap) | the SOAP client, loaded via the customization autoloader |
Integrations & contracts
Two calls, in order, against cpq_endpoint_url (a WSDL):
1. Configure — asks CPQ to configure the part.
'inputParameters' => [
'Application' => ['Instance' => …, 'Name' => …], // cpq_instance, cpq_app_name
'Part' => ['Namespace' => …, 'Name' => …], // the product's ruleset
'Mode' => 'InteractiveRuleset',
'Profile' => …, // cpq_profile
'HeaderDetail' => ['HeaderId' => 'BOM Fetching', 'DetailId' => 'Detail-<random>'],
'IntegrationParameters' => [ /* SiteID, SourceID, SourceLineSuffix, Quantity=1, UOM=EA, CreateUser=false */ ],
'RapidOptions' => ['RapidOption' => $rapidOptions],
]
Anything other than ConfigureResult.Result === 'Success' raises BcCpqConfigurationException carrying
CPQ's own message.
2. LoadMfgData — retrieves the manufacturing data for the configuration just created, correlated by the
same HeaderDetail. The response's BomComponentDto list is walked; each component's Attributes are
scanned for three names — Part Number, Quantity and jobmatl.u_m — and components with a null
ParentId (the configured part itself) are skipped. A component only survives if it has a part number and a
quantity greater than zero.
Session correlation matters. DetailId is 'Detail-'.rand(0, 1000000) and ties the two calls together;
it is also stored on failure reports as the CPQ Detail ID, which is how someone debugging in Infor finds
the same session. Mode is fixed to InteractiveRuleset (the code notes PostConfigurationRuleset and
Constraints as the other options), Quantity is always 1 and UOM always EA — quantities are applied
later, during explosion.
CPQ does not return a BOM. It returns the top-level component list. Those part numbers are then exploded by the same five-level MSSQL query the cache uses — so a CPQ answer still depends on the Infor database connection being up.
How a BOM is exploded
Shared by CPQ and the cache, ConfigboxModelCsimaterials::fetchMaterialsDataViaDb() runs a
single query joining item → job → jobmatl → item five times over, producing one wide "matrix" row
per leaf path. Two conditions carry the business rules:
job.type = 'S' AND (job.suffix = '0' OR job.suffix > 1)— standard jobs only,itemrev1..5.job IS NULL— rows with an item revision at any level are excluded.
The wide rows are then flattened: for each row the code walks from the deepest non-null level upward,
emitting one BcCsiItemMaterial per level, multiplying quantities down the path, and de-duplicating on a
signature (the md5 of the path). Results are sorted by path.
On a query failure the method reconnects and retries itself exactly once, detected by inspecting the call stack — a neat trick, and worth knowing about before you refactor the method name.
Product & question configuration
CPQ needs the site's answers expressed in its vocabulary. That mapping is admin data, not code.
On the product (product edit screen → Infor CPQ Integration; stored in
#__configbox_external_product_appends, migration 0.3.4.php):
| Field | Label | Notes |
|---|---|---|
cpq_has_configuration | Product has a CPQ configuration | the gate — off means the product never reaches the CPQ rung |
cpq_ruleset_namespace | CPQ Ruleset Namespace | defaults to Default |
cpq_ruleset_name | CPQ Ruleset Name | together these form the Part sent to CPQ |
On the question (question edit screen; stored in #__configbox_external_element_appends):
| Field | Label | Notes |
|---|---|---|
cpq_makes_selection | Question makes a CPQ selection | only these questions contribute a RapidOption |
cpq_variable_name | CPQ Option List Name | without the namespace — Luminaire, not BLOCK-PD.Luminaire |
cpq_value_override | CPQ Value Override | send this instead of the real answer code, for answers the ruleset cannot handle |
cpq_override_condition | Override only if one of these codes are selected | comma-separated codes that trigger the override; blank = always override |
cpq_override_notes | Override notes | documentation for whoever inherits the override |
Every override applied is recorded on the position in bom_overrides (variable, original, override) and is
what makes bom_is_exact_match '0' for a CPQ answer. An override is a known inaccuracy — the BOM
returned is for a slightly different configuration than the customer chose.
The override condition is a substring test. The check is
strstr($question->cpq_override_condition, $code)— it asks whether the selected code appears anywhere in the condition string, not whether it is one of the comma-separated entries. A short code can therefore match a longer unrelated entry in the list. Keep condition lists to codes that are not substrings of one another.
Admin settings
Custom Settings → Infor CPQ Integration (all added by migrations 0.3.6, 0.3.9, 0.3.10):
| Setting | Label | Required | Effect if blank |
|---|---|---|---|
cpq_endpoint_url | CPQ Integration API Endpoint URL | yes | the SOAP client cannot be constructed — every CPQ attempt becomes an exception failure |
cpq_instance | CPQ Instance | yes | sent as Application.Instance; CPQ rejects the call |
cpq_app_name | CPQ Application Name | yes | sent as Application.Name; CPQ rejects the call |
cpq_profile | CPQ Profile | yes | sent as Profile; CPQ rejects the call |
cpq_site_id | CPQ Site ID | yes | sent as the SiteID integration parameter |
cpq_fail_reporting_email | Email address for reporting fails because of selections | no | no mail for configuration-class failures (reports are still filed) |
cpq_fail_reporting_email_errors | Email address for reporting fails because of system errors | no | no mail for exception-class failures |
These are entirely separate from the syteline_* (IDO REST) and infor_db_* (MSSQL) groups — see the
Infor hub. CPQ can be down while both of those are healthy.
The two failure keywords
bom_failure_keyword on the position, and failure_keyword on the register row:
| Keyword | Raised when | Who should look |
|---|---|---|
configuration | CPQ answered, but negatively — Result != 'Success', or LoadMfgData returned no BomComponentDto. The rules could not build this combination. | product/ruleset owners — usually an option list or an answer code has drifted |
exception | anything else — SOAP transport failure, WSDL unreachable, malformed response | developers/IT — an infrastructure or contract problem |
They are routed to different email addresses on purpose. Both file a report into
#__configbox_external_cpq_bom_fails including a rendered snapshot of the BOM-status screen, so the evidence
survives even after the position is recalculated.
The interactive tools
CPQ Flat BOM Calculator (adminbccpqbom) takes configuration codes rather than item names and runs
getCpqMaterialsFromCode(): it resolves the first code segment to a product via the linear price list
(ConfigboxModelAdminbcpricelistlinear::getProductIdFromCatRef()), matches the remaining segments against the
product's questions and answer SKUs, then calls CPQ. It shows the configured endpoint so an operator can see
which environment they are hitting.
Compare with the two item-name tools in bom-cache.md — cached versus fresh.
Deployment runbook (manual steps)
- Run migrations (automatic on next page load):
0.3.4(product/question fields),0.3.6,0.3.9,0.3.10(settings columns). - Custom Settings → Infor CPQ Integration: set endpoint URL, instance, application name, profile and site id for this environment. Point non-production at a non-production CPQ.
- Set the two fail-reporting addresses, or leave blank while testing so you are not mailing people.
- Confirm outbound HTTPS to the CPQ host, and that
libs/vendor(nusoap) is installed. - Per product: tick Product has a CPQ configuration and fill in the ruleset namespace and name.
- Per question that drives a CPQ option list: tick Question makes a CPQ selection and set the option list name; add overrides only where the ruleset genuinely cannot take the real code.
- Add the cron entry for
cb_pos_bom_send_report_notifications.php— see scheduled jobs. - Smoke test: open the CPQ Flat BOM Calculator, enter a configuration code for a CPQ-enabled product,
and confirm components come back. Then add a quote line for that product and confirm the position lands on
done/cpq.
Turning it off: untick Product has a CPQ configuration on the product — that product then falls through
to partial matching instead of erroring. Clearing cpq_endpoint_url disables CPQ globally but turns every
attempt into an error position rather than skipping the rung.
Testing
No automated coverage — it needs a live CPQ instance. The CPQ Flat BOM Calculator is the manual test harness; the failure register is the regression signal. See testing/guide.md.
Gotchas & caveats
- CPQ is a third Infor access path. Do not assume the
syteline_*settings or the IDO REST client have anything to do with it. "Infor is up" says nothing about CPQ. - A CPQ product never falls back. See position-boms.md — the
branch ends in
finally { return; }. - CPQ still needs the MSSQL connection, because its part numbers are exploded locally. A VPN outage breaks CPQ-sourced BOMs too.
- Overrides silently change the answer. They are recorded, not flagged; nothing warns the agent that the BOM is for a different configuration.
Quantityis hard-coded to 1 andUOMtoEAin the integration parameters. Line quantity is applied during explosion, not at CPQ.nusoapruns withsetDebugLevel(9), which makes it accumulate debug output in memory on every call.
Possible follow-ups
- Make the override condition an exact list-membership test rather than
strstr(). - Surface "this BOM used overrides" in the UI, not just in
bom_overrides. - Reduce the nusoap debug level, or make it conditional on a debug setting.
Related docs
- Hub: BOM & CPQ · spokes: position-boms.md · bom-cache.md · rest-api.md
- Infor — the other two access paths
- Scheduled jobs · known issues:
docs/_known-issues.md