BOMCOST/i is a discrete-manufacturing engineering-and-cost application: an item master, a
multi-level bill-of-materials with effectivity dates and phantom assemblies, a trigger-maintained
where-used index, a recursive multi-level explosion, a recursive standard-cost roll-up, and a low-level-code
(LLC) computation. Unlike LOANSVC/i there is no RPG orchestration layer and no 5250 screen —
BOMCOST/i is pure SQL PL. Every object is a DB2 for i function, stored procedure, trigger or view,
reached by CALL / SELECT from STRSQL, an SQL script, or an embedded-SQL host
program. This manual is the reference for the operator who runs the explosion and cost-roll cycle, and for
the developer maintaining the recursive SQL PL. It is grounded entirely in the committed source
(sqlpl-app-bom/src/schema.sql, src/routines.sql, and the
test/bom_daily.mjs driver).
BOMCOST/i answers the two core questions of discrete-manufacturing engineering and standard costing:
WITH RECURSIVE
membership test) refuses any BOM edge that would make an item its own descendant.BOMCOST/i lives entirely in the data layer. There is no SQLRPGLE driver and no DDS display file — the LOANSVC/i "SQL PL owns the logic, RPG orchestrates" split does not apply here because there is no RPG side at all. Everything is:
LANGUAGE SQL scalar functions, five stored procedures
(two of them recursive), five triggers, and one view — all in routines.sql, all in
schema BOMCOST. The recursion is expressed two ways: a recursive scalar function
(FN_COST_ROLLUP calls itself) and a recursive stored procedure
(PR_BOM_EXPLODE calls itself), plus WITH RECURSIVE common-table-expression
queries inside the cycle guards.ITMAST,
BOMLINE, WHEREUSED, COSTROLL) and two session-scoped GTTs
(EXPLOSION_WORK for the flattened explosion result, VISITED for the cycle
guard).STRSQL, an
SQL script run under a job, or embedded EXEC SQL CALL in a host program. The bundled
test/bom_daily.mjs driver calls them exactly as an operator would — it is the
de-facto reference client.The benefit for operations: the entire BOM/cost engine is one auditable place (the SQL-PL routines),
callable from anywhere, with no compiled RPG object to maintain. Everything runs in schema/library
BOMCOST.
DATA ENTRY (guarded) READ MODELS CYCLE (recompute)
-------------------- ----------- -----------------
PR_BOM_ADD_LINE --INSERT--> FN_ACTIVE_QTY PR_LLC_COMPUTE
WITH RECURSIVE cycle (effective qty+scrap) -> PR_BOM_EXPLODE_TOP (per top item)
guard, then INSERT FN_COST_ROLLUP (recursive) -> MERGE MAX(LEVELNO) -> ITMAST.ITLLC
| (per-item std cost)
v PR_BOM_EXPLODE_TOP PR_COST_ROLLUP_ALL
BOMLINE ---triggers---> -> PR_BOM_EXPLODE (LLC-desc order)
| \ TR_BOMLINE_CYCLE (recursive, -> WORK) -> FN_COST_ROLLUP per M item
| \ (BEFORE INSERT) -> MERGE COSTROLL + UPDATE ITMAST.ITROLLED
| +--TR_BOMLINE_WHEREUSED_INS/DEL --> WHEREUSED
|
v
GTTs: EXPLOSION_WORK (flattened per-run component list)
VISITED (RUNID-scoped path guard, popped on exit)
A single explosion flows: operator (or the LLC procedure) calls
PR_BOM_EXPLODE_TOP(top, asof, runid) → it clears this run's EXPLOSION_WORK/
VISITED, opens a SAVEPOINT, and calls the recursive worker
PR_BOM_EXPLODE → the worker walks the tree, inserting one EXPLOSION_WORK row
per real (non-phantom) component at each level, pushing/popping VISITED nodes as it descends
and returns. A cost roll flows: PR_COST_ROLLUP_ALL loops manufactured items in LLC-descending
order and calls the recursive FN_COST_ROLLUP per item, upserting COSTROLL via
MERGE.
| Object | Type | Role |
|---|---|---|
| ITMAST | Table | Item master (part, type, UOM, LLC, standard/rolled cost). |
| BOMLINE | Table | BOM edges: parent/component with qty, scrap, phantom, effectivity. |
| WHEREUSED | Table | Trigger-maintained reverse index (component → parent). |
| COSTROLL | Table | Rolled-cost result, one row per item (MERGE-upserted). |
| EXPLOSION_WORK | GTT | Flattened multi-level explosion accumulator (per run). |
| VISITED | GTT | RUNID-scoped ancestor-path cycle guard. |
| FN_ACTIVE_QTY | SQL function | Effective scrap-inflated qty-per as-of a date. |
| FN_COST_ROLLUP | SQL function (recursive) | Recursive standard-cost roll-up per item. |
| PR_BOM_EXPLODE | Procedure (recursive) | Level-by-level explosion worker into EXPLOSION_WORK. |
| PR_BOM_EXPLODE_TOP | Procedure | Explosion entry point (savepoint + reset + call worker). |
| PR_LLC_COMPUTE | Procedure | Low-level-code computation across all top items. |
| PR_COST_ROLLUP_ALL | Procedure | Roll every manufactured item, upsert COSTROLL. |
| PR_BOM_ADD_LINE | Procedure | Guarded BOM-edge insert (cycle-refusing). |
| TR_BOMLINE_WHEREUSED_INS/DEL | Triggers | Maintain WHEREUSED on BOM insert/delete. |
| TR_BOMLINE_CYCLE | Trigger | BEFORE-INSERT cycle guard (recursive-CTE form). |
| TR_COSTED_BOM_IOI | Trigger | INSTEAD OF INSERT rerouting V_COSTED_BOM → BOMLINE. |
| V_COSTED_BOM | View | BOMLINE joined to rolled cost (COMPCOST). |
The full catalogue is 2 functions + 5 procedures + 5 triggers, over 4 base tables, 2 GTTs and 1 view.
Sections D and F expand each. A sixth trigger, TR_BOMLINE_TRANSITION_PROBE, exists only as a
documented platform probe in the driver and is not part of the loaded routine set (see F.3).
Honest statement: BOMCOST/i has no interactive display file, no subfile, and no online
transaction program. It is a pure SQL-PL package. The operator equivalent of "run BOMCOST/i" is
"open an SQL session and CALL a procedure or SELECT a function". Before invoking
anything, the job's library/schema list must include BOMCOST — the tested job runs with
LIBL = QSYS QGPL BOMCOST QTEMP and CURLIB = BOMCOST. Because
EXPLOSION_WORK and VISITED are global temporary tables (session-scoped),
an explosion's results live only for the life of the SQL session that ran it — read them back in the
same session/job.
| To do this | Type in STRSQL (or embed as EXEC SQL) |
|---|---|
| Add a BOM edge (cycle-guarded) | CALL BOMCOST.PR_BOM_ADD_LINE('TOP1','SUB1',2,0.05,'N','2020-01-01','9999-12-31') |
| Effective qty-per of a component as-of a date | VALUES FN_ACTIVE_QTY('SUB1','PART2',DATE '2026-08-01') |
| Standard cost of one item (recursive roll) | VALUES FN_COST_ROLLUP('TOP1',DATE '2026-08-01',1,1) |
| Explode a top item into EXPLOSION_WORK | CALL BOMCOST.PR_BOM_EXPLODE_TOP('TOP1',DATE '2026-08-01',1) |
| Read back the explosion | SELECT LEVELNO,PARENT,COMPONENT,EXQTY FROM EXPLOSION_WORK WHERE TOPITEM='TOP1' ORDER BY LEVELNO,PARENT,COMPONENT |
| Recompute low-level codes for all items | CALL BOMCOST.PR_LLC_COMPUTE(DATE '2026-08-01') |
| Roll every manufactured item's cost | CALL BOMCOST.PR_COST_ROLLUP_ALL(DATE '2026-08-01',100000) |
The two integer parameters worth calling out are P_RUNID / P_RUNBASE:
they scope the VISITED cycle-guard rows to one logical run, so concurrent or repeated calls do
not collide on the shared GTT. Pass a distinct run id per top-level explosion (the LLC procedure uses
900000 + n; the cost roll uses P_RUNBASE + sequence). The P_DEPTH
argument to FN_COST_ROLLUP is the recursion depth and is always seeded 1 by
callers.
SBMJOB-of-a-program idiom
here (there is no program object to submit). To run the cycle as batch, submit a job that runs an SQL script
of the CALLs above (e.g. RUNSQLSTM over a stream file), or drive the procedures
from a host program with embedded SQL. Section C documents the cycle those calls form.Honest statement: BOMCOST/i has no maker–checker approval workflow and no audit-trail
table (there is no LNAUD analogue). It is an engineering/cost-modelling package, not a
posting ledger, so its control posture is structural-integrity enforcement in the data layer
rather than segregation-of-duties. The controls the application actually has:
PR_BOM_ADD_LINE is the primary insert path;
it runs a WITH RECURSIVE membership test before issuing the INSERT and raises
SQLSTATE 90001 if the new edge would close a cycle, and 90004 if an item is
made its own component. TR_BOMLINE_CYCLE is the BEFORE-INSERT trigger form of the same
rule, so even a direct INSERT that bypasses the procedure is guarded (its cheap
self-reference check is what the driver exercises safely — see the platform note in F.3).TR_BOMLINE_WHEREUSED_INS and
TR_BOMLINE_WHEREUSED_DEL insert/delete the reverse-index row automatically on every BOM
line change, so WHEREUSED is always an exact denormalized mirror of BOMLINE
(the driver verifies 7 edges → 7 where-used rows, and that a diamond-reused component lists both
parents).P_ASOF BETWEEN EFFFROM AND EFFTO), so an engineering change is a matter of closing one
revision's EFFTO and opening the next revision's EFFFROM — history is
preserved, and a cost/explosion run for a past date still sees the revision that was active then.VISITED-based runtime guard (SQLSTATE 90001) and a depth ceiling of 40
(SQLSTATE 90002), so a cyclic or runaway structure can never loop the engine forever.TR_COSTED_BOM_IOI (INSTEAD OF INSERT on
V_COSTED_BOM) reroutes an insert to the base BOMLINE and silently drops the
derived COMPCOST, so the rolled-cost column cannot be written through the view.In sum, the control model is structural-integrity + effectivity + cycle-safety, enforced in the data layer — appropriate for a BOM/cost engine, not an approval gate.
BOMCOST/i has no scheduled RPG job; its "batch cycle" is a fixed sequence of SQL-PL procedure
calls — explode, compute low-level codes, roll costs — run as-of a chosen date. Every
procedure and function takes an explicit P_ASOF DATE (there is no control-row table like
LOANSVC/i's LNCTL; the date is a call parameter), so a run is fully parameterised at the
call site. The natural driver is an SQL script (or the bundled test/bom_daily.mjs) submitted
as a job.
-- the daily/periodic recompute cycle, as-of a date, from STRSQL or a script CALL BOMCOST.PR_LLC_COMPUTE(DATE '2026-08-01'); -- 1. refresh low-level codes CALL BOMCOST.PR_COST_ROLLUP_ALL(DATE '2026-08-01', 100000); -- 2. roll every manufactured cost -- on demand: explode a single top item for a report CALL BOMCOST.PR_BOM_EXPLODE_TOP('TOP1', DATE '2026-08-01', 1);
| Routine | Kind | Purpose | Calls / fires | Inputs | Effect |
|---|---|---|---|---|---|
| PR_BOM_ADD_LINE | Proc | Guarded BOM-edge insert. | WITH RECURSIVE cycle probe, then INSERT → fires TR_BOMLINE_CYCLE + TR_BOMLINE_WHEREUSED_INS. | parent, component, qtyper, scrappct, phantom, efffrom, effto. | One BOMLINE row + one WHEREUSED row; or SIGNAL 90001/90004. |
| PR_BOM_EXPLODE_TOP | Proc | Explosion entry point for one top item. | Clears run's WORK/VISITED, SAVEPOINT SP_EXPLODE, calls PR_BOM_EXPLODE. | topitem, asof, runid. | Populates EXPLOSION_WORK for topitem; rolls back cleanly on any failure. |
| PR_BOM_EXPLODE | Proc (recursive) | Level-by-level explosion worker. | Recurses on each manufactured child; phantom passes through at same level. | topitem, parent, level, cumqty, asof, runid. | One EXPLOSION_WORK row per real component per level; VISITED push/pop. |
| PR_LLC_COMPUTE | Proc | Low-level-code recompute. | Outer cursor over top items → PR_BOM_EXPLODE_TOP each → MERGE MAX(LEVELNO). | asof. | Resets then sets ITMAST.ITLLC for every used component. |
| PR_COST_ROLLUP_ALL | Proc | Roll every manufactured item. | Cursor over M items in ITLLC-DESC order → FN_COST_ROLLUP each → MERGE COSTROLL. | asof, runbase. | Upserts COSTROLL.MATCOST and updates ITMAST.ITROLLED. |
| FN_ACTIVE_QTY | Func | Effective scrap-inflated qty-per. | — (leaf lookup on BOMLINE). | parent, component, asof. | Returns qtyper/(1-scrappct); NULL if no active line; SIGNAL 90005 if scrap≥100%. |
| FN_COST_ROLLUP | Func (recursive) | Standard cost of one item. | Recurses per manufactured child; FN_ACTIVE_QTY for qty; VISITED guard. | item, asof, runid, depth. | Returns rolled cost; SIGNAL 90001/90002/90003 on cycle/depth/unknown. |
| TR_BOMLINE_WHEREUSED_INS | Trigger | Maintain where-used on insert. | AFTER INSERT on BOMLINE. | NEW row. | Inserts matching WHEREUSED row. |
| TR_BOMLINE_WHEREUSED_DEL | Trigger | Cascade where-used on delete. | AFTER DELETE on BOMLINE. | OLD row. | Deletes matching WHEREUSED row by BOMID. |
| TR_BOMLINE_CYCLE | Trigger | BEFORE-INSERT cycle guard. | BEFORE INSERT on BOMLINE (recursive-CTE membership test). | NEW row. | SIGNAL 90004 self-ref / 90001 would-close-cycle. |
| TR_COSTED_BOM_IOI | Trigger | View insert rerouting. | INSTEAD OF INSERT on V_COSTED_BOM. | NEW row (COMPCOST ignored). | Inserts into base BOMLINE. |
The entry procedure clears this run's EXPLOSION_WORK/VISITED rows, opens
SAVEPOINT SP_EXPLODE, and calls the worker with the top item as both top and parent at level 1
with cumulative quantity 1. The worker, for each active child (date-qualified, ORDER BY COMPONENT):
computes EXQTY = P_CUMQTY × qtyper/(1-scrappct); if the child is a phantom
(PHANTOM='Y') it writes no row for the phantom itself but recurses into the phantom's
children at the same level (classic phantom pass-through); otherwise it inserts one
EXPLOSION_WORK row and, if the child is manufactured, recurses at level+1. A
missing component master is skipped via a CONTINUE HANDLER FOR NOT FOUND rather than aborting.
On any exception the entry procedure rolls back to the savepoint and clears the run's VISITED,
so a partial explosion never leaks.
Hand-oracle explosion of 1x TOP1 (from test/bom_daily.mjs) -- 8 rows, SUB2 is a phantom so it never gets its own row; SUB1 recurs via the diamond: L1 TOP1 -> SUB1 exqty 2.105263 (2/(1-0.05)) L1 SUB2 -> PART3 exqty 4 (phantom pass-through, parent kept as SUB2) L1 SUB2 -> SUB1 exqty 1 (diamond: SUB1 reused under the phantom) L1 TOP1 -> PART4 exqty 5 L2 SUB1 -> PART1 exqty 6.315789 + 3 = 9.315789 total (both SUB1 paths) L2 SUB1 -> PART2 exqty 2.339181 + 1.111111 = 3.450292 total (both SUB1 paths)
Resets every ITLLC to 0, then loops every top-level item (one that is never itself a
component in an active BOM line), exploding each into EXPLOSION_WORK and folding the rows into
a shared '*LLCSCAN' bucket. A final MERGE sets each component's ITLLC
to the MAX(LEVELNO) seen across all roots. Hand oracle:
TOP1=0 SUB1=1 PART1=2 PART2=2 PART3=1 PART4=1.
DELETE/UPDATE sets SQLCODE +100 (SQLSTATE 02000), which would trip the in-scope
CONTINUE HANDLER FOR NOT FOUND and set V_DONE=1 before the cursor ever opens. The
procedure therefore resets SET V_DONE = 0 immediately before OPEN C_TOPS so the
housekeeping DML can never leak into the loop's own NOT-FOUND signal (this is real Db2-for-i behaviour, per
SQLPL-SIM-FINDINGS.md CLM-05).Cursors over manufactured items in ITLLC DESC, ITEM order (deepest first — the
bottom-up MRP sequencing), calls FN_COST_ROLLUP(item, asof, runbase+seq, 1) per item, and
MERGEs the result into COSTROLL (update-in-place or insert), then updates
ITMAST.ITROLLED. Because FN_COST_ROLLUP also recurses on demand, the LLC ordering
is a performance/clarity convention, not a correctness requirement. Hand oracle:
SUB1=11.555556 SUB2=17.555556 TOP1=45.633041.
PR_BOM_ADD_LINE or a
guarded insert) so BOMLINE and WHEREUSED are populated before an explosion
or roll means anything.PR_COST_ROLLUP_ALL orders by
ITLLC, so run PR_LLC_COMPUTE first to get the deepest-first sequence. It is
not a hard requirement (the recursive function costs children on demand), but it is the clean
bottom-up order and what the driver runs.EXPLOSION_WORK rows are session-scoped;
read them back in the same SQL session/job that ran the explosion.RUNID so the
shared VISITED guard rows never collide (the procedures do this automatically:
900000+n for LLC, runbase+seq for the roll).P_ASOF to LLC and the cost
roll (and to any explosion you reconcile against them) so all three see the same effective revisions.All objects are in schema/library BOMCOST, grounded in schema.sql. Costs are
DECIMAL at scale 6; quantities are DECIMAL; scrap is a fraction
(0.02 = 2%); effectivity is a real DATE. Four tables persist; two are global
temporary tables.
| Field | Type | Meaning |
|---|---|---|
| ITEM | CHAR(15) | Part number (PK). |
| ITDESC | VARCHAR(40) | Item description. |
| ITTYPE | CHAR(1) | M manufactured (cost is rolled) / P purchased (cost is given). |
| ITUOM | CHAR(3) | Unit of measure (default EA). |
| ITLLC | SMALLINT | Low-level code — deepest level the item appears at in any BOM (set by PR_LLC_COMPUTE; default 0). |
| ITSTDCOST | DECIMAL(15,6) | Standard cost: given for purchased, rolled for manufactured (default 0). |
| ITROLLED | DECIMAL(15,6) | Last rolled cost (NULL until PR_COST_ROLLUP_ALL runs). |
Component scrap is a property of the usage (the BOM line), not the item — so
there is no scrap column here; it lives on BOMLINE.SCRAPPCT.
| Field | Type | Meaning |
|---|---|---|
| BOMID | INTEGER identity | Generated-always surrogate key (PK). |
| PARENT | CHAR(15) | Parent item (the assembly). |
| COMPONENT | CHAR(15) | Component consumed to build the parent. |
| QTYPER | DECIMAL(13,4) | Quantity of component per one parent, before scrap. |
| SCRAPPCT | DECIMAL(7,4) | Scrap fraction on this usage (0.02 = 2%); consumption = QTYPER/(1-SCRAPPCT). |
| PHANTOM | CHAR(1) | Y = phantom assembly (transparent in explosion listing); default N. |
| EFFFROM | DATE | Revision effective-from date. |
| EFFTO | DATE | Revision effective-to date (default 9999-12-31). |
The same (PARENT, COMPONENT) may appear more than once with non-overlapping date
ranges — an engineering change. Reads select the revision where asof BETWEEN EFFFROM AND EFFTO.
| Field | Type | Meaning |
|---|---|---|
| COMPONENT | CHAR(15) | The component (index key). |
| PARENT | CHAR(15) | A parent that uses it. |
| BOMID | INTEGER | The BOMLINE edge this row mirrors. |
Denormalized "where is this part used" index, maintained in exact sync with BOMLINE by
TR_BOMLINE_WHEREUSED_INS / _DEL. One WHEREUSED row per BOM edge.
| Field | Type | Meaning |
|---|---|---|
| ITEM | CHAR(15) | Item (PK). |
| MATCOST | DECIMAL(17,6) | Rolled material cost (components incl. scrap). |
| LLC | SMALLINT | Low-level code snapshot at roll time. |
| COSTDATE | DATE | When the roll ran (the P_ASOF used). |
Upserted by PR_COST_ROLLUP_ALL via MERGE — one row per item,
update-in-place on re-run (no duplication).
| Field | Type | Meaning |
|---|---|---|
| TOPITEM | CHAR(15) | The top item this row belongs to (or '*LLCSCAN' in the LLC pass). |
| LEVELNO | SMALLINT | BOM level (top's direct children = 1). |
| PARENT | CHAR(15) | Immediate parent (a phantom's identity is kept here even though it gets no row of its own). |
| COMPONENT | CHAR(15) | Component at this level. |
| EXQTY | DECIMAL(19,6) | Cumulative quantity of COMPONENT per one TOPITEM (product of every scrap-inflated qty down the path). |
| ISPHANTOM | CHAR(1) | Retained flag (real component rows are inserted with 'N'). |
DECLARE GLOBAL TEMPORARY TABLE ... ON COMMIT PRESERVE ROWS WITH REPLACE —
session-scoped; rows survive commits within the session but not across sessions.
| Field | Type | Meaning |
|---|---|---|
| RUNID | INTEGER | Logical-run scope (so concurrent/repeated runs don't collide). |
| NODE | CHAR(15) | An item currently on the recursion path for this run. |
A row is inserted when a recursive routine enters a node and deleted when it
leaves, so a node found already present (same RUNID) is an ancestor — a cycle — and
trips SQLSTATE 90001. Popping on exit is what lets a legal diamond (a component reached by two
different paths) reconverge without a false cycle.
Joins BOMLINE to COSTROLL (LEFT JOIN on component), exposing
BOMID, PARENT, COMPONENT, QTYPER, SCRAPPCT, PHANTOM, EFFFROM, EFFTO plus the derived
COMPCOST (the component's rolled MATCOST). Insertable only through
TR_COSTED_BOM_IOI (INSTEAD OF INSERT), which reroutes to BOMLINE and drops the
read-only COMPCOST.
Pre-checks: confirm the job's schema list includes BOMCOST; decide the as-of date
(the effective revisions are selected by it); use the same as-of date for the whole cycle.
CALL BOMCOST.PR_BOM_ADD_LINE(parent, component, qtyper, scrappct, phantom, efffrom, effto).
An engineering change is: UPDATE BOMLINE SET EFFTO='<day-before>' on the old
revision, then PR_BOM_ADD_LINE the new revision with the new EFFFROM.CALL BOMCOST.PR_LLC_COMPUTE(DATE '<asof>').CALL BOMCOST.PR_COST_ROLLUP_ALL(DATE '<asof>', <runbase>)
— pass a fresh runbase (e.g. a timestamp-derived integer) each run.CALL BOMCOST.PR_BOM_EXPLODE_TOP('<top>', DATE '<asof>', <runid>), then read
EXPLOSION_WORK back in the same session.Post-checks:
CALL returns SQLCODE 0 (no SIGNALed 9000x SQLSTATE).WHEREUSED row count equals the BOMLINE row count (the trigger kept them in sync).EXPLOSION_WORK for a top item has the expected row count — a phantom never gets its
own row, a diamond component appears once per reaching path; VISITED for that run is
empty afterward (every node popped on a clean exit).COSTROLL has exactly one row per manufactured item; a re-run leaves the counts and costs
unchanged (MERGE upsert, stable BOM → stable cost).These are the same figures the bundled volume driver checks against an independent hand-derived oracle
(Python Decimal cross-checked). Use them to reconcile a run:
| Figure | Definition | Worked example (from the driver's oracle) |
|---|---|---|
| Effective qty-per | QTYPER / (1 - SCRAPPCT) for the active revision as-of the date. |
SUB1→PART2 as-of 2026-08-01: 1/(1-0.10) = 1.111111; as-of 2027-06-01 (new rev): 2/(1-0.10) = 2.222222. |
| Cumulative explosion qty | Product of every scrap-inflated qty-per down the path from top to component, summed across paths. | PART1 under 1×TOP1 across both SUB1 paths = 6.315789 + 3 = 9.315789. |
| Rolled cost (leaf-up) | Purchased = ITSTDCOST; manufactured = Σ(child rolled cost × effective qty-per). | SUB1 = 2.00×3 + 5.00×(1/0.90) = 6.00 + 5.555556 = 11.555556. |
| Phantom cost | A phantom's cost is still the roll of its own components (PHANTOM only changes explosion listing). | SUB2 = 1.50×4 + SUB1×1 = 6.00 + 11.555556 = 17.555556. |
| Top rolled cost | Full multi-level roll incl. diamond re-use. | TOP1 = 11.555556×(2/0.95) + 17.555556 + 0.75×5 = 45.633041. |
| Low-level codes | MAX(LEVELNO) each component appears at across all roots. | TOP1=0 SUB1=1 PART1=2 PART2=2 PART3=1 PART4=1. |
Ad-hoc cross-check: a plain WITH RECURSIVE explosion of the same top item over
BOMLINE should reproduce the cumulative quantities (note: a bare recursive CTE has no
procedural IF, so it does not special-case phantoms — it lists a phantom as a
real level-1 row, producing 9 rows where PR_BOM_EXPLODE produces 8; both are correct for their
respective, documented semantics). See F.4 for the CTE.
Every routine either returns SQLCODE 0 or SIGNALs a specific application SQLSTATE (section F.5). The procedures are designed to be safely re-runnable.
| Situation | Behaviour | Action |
|---|---|---|
| Explosion fails partway | PR_BOM_EXPLODE_TOP's EXIT handler rolls back to SAVEPOINT SP_EXPLODE and clears the run's VISITED, then RESIGNALs. | No partial EXPLOSION_WORK is left for that top. Fix the data and re-call — the entry procedure clears the run's rows first, so it is idempotent per top item. |
| Re-run cost roll-up | MERGE updates COSTROLL in place; count stays at one row per item. | Safe. A stable BOM re-rolls to the same cost (verified: TOP1 unchanged, still exactly 3 rows). |
| Re-run LLC | ITLLC reset to 0 then re-derived from a fresh scan. | Idempotent — same codes each run for a stable BOM. |
| SQLSTATE 90001 (cyclic BOM) | A recursive routine found the node already on its VISITED path. | Normally unreachable via data entry (the insert guards prevent a cyclic BOMLINE). If seen, a run id was reused with stale VISITED rows — clear VISITED for that run id and use a fresh one. |
| SQLSTATE 90002 (depth > 40) | Explosion/roll exceeded the depth ceiling. | Real BOM trees are shallow; a depth-40 trip means a pathological or cyclic structure — inspect the tree around the reported node. |
| SQLSTATE 90003 (unknown item) | FN_COST_ROLLUP was asked to cost an item with no ITMAST row. | Add the item master row (no silent NULL/zero cost is returned). Note the explosion procedure instead skips a missing component. |
| SQLSTATE 90005 (scrap ≥ 100%) | A BOM line has SCRAPPCT ≥ 1, which would divide by zero/negative. | Correct the offending BOM line's scrap percentage. |
| Cross-session GTT read returns nothing | EXPLOSION_WORK/VISITED are session-scoped. | Read the explosion in the same session that ran it, or re-explode in the reading session. |
TR_BOMLINE_CYCLE is the belt-and-suspenders
form of the insert guard, but on this engine a BEFORE row trigger sees the table with the NEW row already
physically present (platform finding SQLPL-PLAT-BOM-01), so its recursive-CTE check must not be exercised
against a truly cycle-closing edge (the CTE would walk a genuinely cyclic graph). The primary,
safe insert path is therefore PR_BOM_ADD_LINE, whose recursive check runs before
the INSERT; the trigger's cheap self-reference guard (non-recursive) is unaffected and always fires. See F.3.The complete SQL-PL surface, from routines.sql. All objects are in schema
BOMCOST. This is the fullest section: every function, procedure, trigger and view with its
signature and semantics, then the recursive patterns and the SQLSTATE table.
P_ASOF BETWEEN EFFFROM AND EFFTO, FETCH FIRST 1 ROW ONLY);
returns NULL if no line is effective; SIGNALs 90005 if scrap ≥ 1; otherwise
returns QTYPER / (1 - SCRAPPCT). A phantom's own qty-per still applies here — it is
the phantom's parent link that is transparent, handled in the explosion procedure, not in
this function.ITTYPE='P') → returns
ITSTDCOST. Manufactured → opens a cursor over active children and returns
Σ(FN_COST_ROLLUP(child) × FN_ACTIVE_QTY(item,child)). A phantom child is costed
identically to a real one (PHANTOM changes only explosion listing, never costing arithmetic).
Guards: depth > 40 → 90002; node already in VISITED for this
run → 90001; unknown item → 90003. It inserts its node into
VISITED on entry and deletes it on exit so a diamond re-convergence is not a false
cycle. Each recursion frame wraps the child call in an EXIT HANDLER FOR SQLSTATE '90001'
that closes its cursor and RESIGNALs with its own parent context appended — the
RESIGNAL-across-frames pattern (F.4).90004).
Runs a WITH RECURSIVE SUBTREE query asking "does P_PARENT appear anywhere in
P_COMPONENT's own sub-tree?"; if so the edge would close a cycle → 90001.
Only then does it INSERT into BOMLINE (firing the where-used and cycle
triggers). Because the check runs before the INSERT, the candidate edge is never physically
present while the CTE walks the graph.90002; parent already
VISITED → 90001; else pushes parent to VISITED and opens a
cursor over active children (ORDER BY COMPONENT). Per child: a nested
CONTINUE HANDLER FOR NOT FOUND around the ITMAST lookup lets it
skip a missing component (ITERATE); scrap ≥ 1 → 90005;
V_EXQTY = P_CUMQTY × QTYPER/(1-SCRAPPCT). If the child is a phantom it writes
no row and recurses at the same level; otherwise it inserts one EXPLOSION_WORK
row and, if manufactured, recurses at level+1. Each recursive call is wrapped in an
EXIT HANDLER FOR SQLSTATE '90001' that closes the cursor and RESIGNALs with context.
Pops parent from VISITED on exit.EXIT HANDLER FOR SQLEXCEPTION rolls back to
SAVEPOINT SP_EXPLODE, clears the run's VISITED, and RESIGNALs. Clears this
run's EXPLOSION_WORK/VISITED, opens the savepoint, calls
PR_BOM_EXPLODE(top, top, 1, 1, asof, runid), and releases the savepoint on success.
Note: the standard ON ROLLBACK RETAIN CURSORS clause is not accepted by this engine's
SAVEPOINT passthrough (SQLPL-PLAT-BOM-02); a bare SAVEPOINT is used and is sufficient
(no cursor must survive the rollback boundary).ITLLC=0; cursors over true top items
(NOT EXISTS an active line using them as a component); explodes each via
PR_BOM_EXPLODE_TOP (run ids 900000+n) and folds the rows into a shared
'*LLCSCAN' bucket; a final MERGE ... WHEN MATCHED THEN UPDATE SET ITLLC = MAX(LEVELNO)
sets each component's code. Resets V_DONE=0 before OPEN to keep a zero-row
housekeeping DELETE's +100/02000 from tripping the loop's NOT-FOUND handler (see C.2 note).ITTYPE='M' in ITLLC DESC, ITEM
order (bottom-up); calls FN_COST_ROLLUP(item, asof, P_RUNBASE + seq, 1);
MERGEs the result into COSTROLL (matched → update MATCOST/COSTDATE;
not matched → insert with the item's current ITLLC); then UPDATE ITMAST SET ITROLLED.
Purchased items keep their given ITSTDCOST and are not re-rolled.(N.COMPONENT, N.PARENT, N.BOMID) into WHEREUSED — the reverse
index is grown with every BOM edge.WHEREUSED row for O.BOMID — the reverse index is cascaded
out when a BOM edge is removed.N.PARENT = N.COMPONENT (90004)
and, via the same WITH RECURSIVE SUBTREE membership test as PR_BOM_ADD_LINE,
an edge that would make an item its own descendant (90001). It proves the guard also
holds for a direct insert that bypasses the procedure.BOMLINE, silently dropping the
derived read-only COMPCOST.BOMLINE LEFT JOIN COSTROLL on component, exposing the BOM columns plus
COMPCOST = C.MATCOST.TR_BOMLINE_CYCLE's recursive-CTE membership test this is fatal for the
exact case that matters — an edge that truly closes a cycle makes the graph the CTE walks genuinely
cyclic at fire time, so the query does not terminate. The safe primary path is therefore
PR_BOM_ADD_LINE (its check runs before the INSERT). The trigger's cheap, non-recursive
self-reference guard is unaffected and always fires, and the driver verifies it does not false-positive a
legitimate non-cyclic direct insert. Separately, a probe trigger
TR_BOMLINE_TRANSITION_PROBE (AFTER UPDATE referencing OLD_TABLE/NEW_TABLE
statement-level transition tables) is not loaded by the routine set — the driver issues it in
isolation to document that the engine silently mis-parses the single-token transition-table spelling
(SQLPL-PLAT-BOM-03). Neither of these is a BOMCOST/i application bug; both are recorded platform findings.BOMCOST/i deliberately exercises the recursive corners of SQL PL. The reusable patterns:
Each recursion level opens its own cursor over that level's children and, for a manufactured child,
CALLs itself at level+1; a deep tree nests one cursor per stack frame. A phantom
recurses at the same level (no row, no depth). The pattern:
-- inside PR_BOM_EXPLODE, per child (abridged) SET V_EXQTY = P_CUMQTY * (V_QTY / (1 - V_SCRAP)); IF V_PHANTOM = 'Y' THEN CALL PR_BOM_EXPLODE(P_TOPITEM, V_COMP, P_LEVEL, V_EXQTY, P_ASOF, P_RUNID); -- same level ELSE INSERT INTO EXPLOSION_WORK VALUES (P_TOPITEM, P_LEVEL, P_PARENT, V_COMP, V_EXQTY, 'N'); IF V_TYPE = 'M' THEN CALL PR_BOM_EXPLODE(P_TOPITEM, V_COMP, P_LEVEL + 1, V_EXQTY, P_ASOF, P_RUNID); -- descend END IF; END IF;
Cost is a sum over children of child-cost × effective qty; the child-cost is the same function recursing:
-- inside FN_COST_ROLLUP, per child (abridged)
SET V_EXQTY = FN_ACTIVE_QTY(P_ITEM, V_COMP, P_ASOF);
SET V_CHILDCOST = FN_COST_ROLLUP(V_COMP, P_ASOF, P_RUNID, P_DEPTH + 1);
SET V_TOTAL = V_TOTAL + (V_CHILDCOST * V_EXQTY);
A RUNID-scoped row marks every node currently on the path; finding a node already present is a cycle. Popping on exit is what makes a legal diamond (a component reached by two paths) not a false cycle:
SELECT COUNT(*) INTO V_HIT FROM VISITED WHERE RUNID = P_RUNID AND NODE = P_ITEM; IF V_HIT > 0 THEN SIGNAL SQLSTATE '90001' SET MESSAGE_TEXT = '...cyclic BOM...'; END IF; INSERT INTO VISITED (RUNID, NODE) VALUES (P_RUNID, P_ITEM); ... recurse over children ... DELETE FROM VISITED WHERE RUNID = P_RUNID AND NODE = P_ITEM; -- pop on the way out
Every frame catches 90001, closes its own cursor, and re-raises with its parent context
appended, so the top-level caller sees the full path while every frame cleans up:
BEGIN
DECLARE EXIT HANDLER FOR SQLSTATE '90001'
BEGIN
CLOSE C_KIDS;
RESIGNAL SQLSTATE '90001' SET MESSAGE_TEXT = '...cycle while costing ' || P_ITEM || ' -> ' || V_COMP;
END;
SET V_CHILDCOST = FN_COST_ROLLUP(V_COMP, P_ASOF, P_RUNID, P_DEPTH + 1);
END;
The entry procedure opens a savepoint and an SQLEXCEPTION handler rolls back to it so a
failed explosion leaves no partial EXPLOSION_WORK:
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK TO SAVEPOINT SP_EXPLODE; DELETE FROM VISITED WHERE RUNID = P_RUNID; RESIGNAL; END; DELETE FROM EXPLOSION_WORK WHERE TOPITEM = P_TOPITEM AND LEVELNO >= 0; SAVEPOINT SP_EXPLODE; CALL PR_BOM_EXPLODE(P_TOPITEM, P_TOPITEM, 1, 1, P_ASOF, P_RUNID); RELEASE SAVEPOINT SP_EXPLODE;
The insert guards use a recursive CTE as the membership test itself (not a side probe); the same shape serves an ad-hoc multi-level explosion or upward where-used closure:
-- cycle-membership test in PR_BOM_ADD_LINE / TR_BOMLINE_CYCLE WITH RECURSIVE SUBTREE (NODE) AS ( SELECT COMPONENT FROM BOMCOST.BOMLINE WHERE PARENT = P_COMPONENT UNION ALL SELECT B.COMPONENT FROM BOMCOST.BOMLINE B JOIN SUBTREE S ON B.PARENT = S.NODE ) SELECT NODE FROM SUBTREE WHERE NODE = P_PARENT; -- any hit => would close a cycle -- ad-hoc full explosion (phantom-UNAWARE: lists a phantom as a real level-1 row) WITH RECURSIVE EXPL (LEVELNO, PARENT, COMPONENT, EXQTY) AS ( SELECT 1, B.PARENT, B.COMPONENT, CAST(B.QTYPER/(1-B.SCRAPPCT) AS DECIMAL(19,6)) FROM BOMCOST.BOMLINE B WHERE B.PARENT = 'TOP1' AND DATE '2026-08-01' BETWEEN B.EFFFROM AND B.EFFTO UNION ALL SELECT E.LEVELNO+1, B.PARENT, B.COMPONENT, CAST(E.EXQTY*(B.QTYPER/(1-B.SCRAPPCT)) AS DECIMAL(19,6)) FROM EXPL E JOIN BOMCOST.BOMLINE B ON B.PARENT = E.COMPONENT AND DATE '2026-08-01' BETWEEN B.EFFFROM AND B.EFFTO ) SELECT LEVELNO, PARENT, COMPONENT, EXQTY FROM EXPL ORDER BY LEVELNO, PARENT, COMPONENT;
PR_BOM_EXPLODE is
phantom-aware (8 rows for the sample TOP1 — SUB2 the phantom gets no row), while the bare
WITH RECURSIVE has no procedural IF and so lists the phantom as a real row (9
rows). Both are correct for their documented semantics; pick the phantom-aware procedure for a real MRP
component list.Both PR_LLC_COMPUTE (fold MAX(LEVELNO) into ITMAST.ITLLC) and
PR_COST_ROLLUP_ALL (upsert COSTROLL) use MERGE so a re-run updates in place rather
than duplicating. COSTROLL.MATCOST is DECIMAL(17,6); a raw MERGE of an arithmetic
expression may carry a documented decimal-quantization residual (SQLPL-SIM-FINDINGS.md), which is an
informational platform note, not an application defect.
| SQLSTATE | Raised by | Meaning |
|---|---|---|
| 90001 | PR_BOM_ADD_LINE / TR_BOMLINE_CYCLE / FN_COST_ROLLUP / PR_BOM_EXPLODE | Cyclic BOM — the edge would close a cycle, or a node is already on the recursion path (VISITED hit). |
| 90002 | FN_COST_ROLLUP / PR_BOM_EXPLODE | Max explosion depth (40) exceeded — a runaway or cycle the VISITED guard did not catch. |
| 90003 | FN_COST_ROLLUP | Unknown item — no ITMAST row for the item being costed (no silent NULL/zero). |
| 90004 | PR_BOM_ADD_LINE / TR_BOMLINE_CYCLE | An item cannot be its own component (self-reference). |
| 90005 | FN_ACTIVE_QTY / PR_BOM_EXPLODE | Scrap percentage ≥ 100% — would divide by zero/negative. |