BOMCOST/i — Multi-Level BOM Explosion & Standard-Cost Roll-Up

SteelFrame X application operation manual  ·  ← back to Operation Manuals  ·  Sign On

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).

Contents

A. Overview & Architecture ↑ top

A.1 What it does

BOMCOST/i answers the two core questions of discrete-manufacturing engineering and standard costing:

A.2 Pure-SQL-PL architecture (no RPG, no 5250 screen)

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:

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.

A.3 Component & flow

  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.

A.4 Object inventory

ObjectTypeRole
ITMASTTableItem master (part, type, UOM, LLC, standard/rolled cost).
BOMLINETableBOM edges: parent/component with qty, scrap, phantom, effectivity.
WHEREUSEDTableTrigger-maintained reverse index (component → parent).
COSTROLLTableRolled-cost result, one row per item (MERGE-upserted).
EXPLOSION_WORKGTTFlattened multi-level explosion accumulator (per run).
VISITEDGTTRUNID-scoped ancestor-path cycle guard.
FN_ACTIVE_QTYSQL functionEffective scrap-inflated qty-per as-of a date.
FN_COST_ROLLUPSQL function (recursive)Recursive standard-cost roll-up per item.
PR_BOM_EXPLODEProcedure (recursive)Level-by-level explosion worker into EXPLOSION_WORK.
PR_BOM_EXPLODE_TOPProcedureExplosion entry point (savepoint + reset + call worker).
PR_LLC_COMPUTEProcedureLow-level-code computation across all top items.
PR_COST_ROLLUP_ALLProcedureRoll every manufactured item, upsert COSTROLL.
PR_BOM_ADD_LINEProcedureGuarded BOM-edge insert (cycle-refusing).
TR_BOMLINE_WHEREUSED_INS/DELTriggersMaintain WHEREUSED on BOM insert/delete.
TR_BOMLINE_CYCLETriggerBEFORE-INSERT cycle guard (recursive-CTE form).
TR_COSTED_BOM_IOITriggerINSTEAD OF INSERT rerouting V_COSTED_BOM → BOMLINE.
V_COSTED_BOMViewBOMLINE 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).

B. Online / Access ↑ top

B.1 STRSQL / CALL access — there is no 5250 screen

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 thisType 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 dateVALUES 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_WORKCALL BOMCOST.PR_BOM_EXPLODE_TOP('TOP1',DATE '2026-08-01',1)
Read back the explosionSELECT LEVELNO,PARENT,COMPONENT,EXQTY FROM EXPLOSION_WORK WHERE TOPITEM='TOP1' ORDER BY LEVELNO,PARENT,COMPONENT
Recompute low-level codes for all itemsCALL BOMCOST.PR_LLC_COMPUTE(DATE '2026-08-01')
Roll every manufactured item's costCALL 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.

There is no menu, no command-entry program, and no 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.

B.2 Controls & audit workflow

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:

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.

C. Batch / Cycle Procedures ↑ top

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);

C.1 Full procedure / trigger set

RoutineKindPurposeCalls / firesInputsEffect
PR_BOM_ADD_LINEProc 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_TOPProc 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_EXPLODEProc (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_COMPUTEProc 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_ALLProc 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_QTYFunc 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_ROLLUPFunc (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_INSTrigger Maintain where-used on insert. AFTER INSERT on BOMLINE. NEW row. Inserts matching WHEREUSED row.
TR_BOMLINE_WHEREUSED_DELTrigger Cascade where-used on delete. AFTER DELETE on BOMLINE. OLD row. Deletes matching WHEREUSED row by BOMID.
TR_BOMLINE_CYCLETrigger 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_IOITrigger View insert rerouting. INSTEAD OF INSERT on V_COSTED_BOM. NEW row (COMPCOST ignored). Inserts into base BOMLINE.

C.2 The explosion / cost-roll cycle in detail

Explosion — PR_BOM_EXPLODE_TOP → PR_BOM_EXPLODE

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)

Low-level codes — PR_LLC_COMPUTE

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.

A subtle, source-documented ordering issue lives here: a zero-row housekeeping 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).

Cost roll-up — PR_COST_ROLLUP_ALL

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.

C.3 Ordering & dependencies

D. Data Files (data dictionary) ↑ top

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.

ITMAST — Item master (PK ITEM)

FieldTypeMeaning
ITEMCHAR(15)Part number (PK).
ITDESCVARCHAR(40)Item description.
ITTYPECHAR(1)M manufactured (cost is rolled) / P purchased (cost is given).
ITUOMCHAR(3)Unit of measure (default EA).
ITLLCSMALLINTLow-level code — deepest level the item appears at in any BOM (set by PR_LLC_COMPUTE; default 0).
ITSTDCOSTDECIMAL(15,6)Standard cost: given for purchased, rolled for manufactured (default 0).
ITROLLEDDECIMAL(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.

BOMLINE — BOM edges (PK BOMID identity; PARENT/COMPONENT per effectivity revision)

FieldTypeMeaning
BOMIDINTEGER identityGenerated-always surrogate key (PK).
PARENTCHAR(15)Parent item (the assembly).
COMPONENTCHAR(15)Component consumed to build the parent.
QTYPERDECIMAL(13,4)Quantity of component per one parent, before scrap.
SCRAPPCTDECIMAL(7,4)Scrap fraction on this usage (0.02 = 2%); consumption = QTYPER/(1-SCRAPPCT).
PHANTOMCHAR(1)Y = phantom assembly (transparent in explosion listing); default N.
EFFFROMDATERevision effective-from date.
EFFTODATERevision 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.

WHEREUSED — Reverse index (PK COMPONENT, PARENT, BOMID)

FieldTypeMeaning
COMPONENTCHAR(15)The component (index key).
PARENTCHAR(15)A parent that uses it.
BOMIDINTEGERThe 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.

COSTROLL — Rolled-cost result (PK ITEM)

FieldTypeMeaning
ITEMCHAR(15)Item (PK).
MATCOSTDECIMAL(17,6)Rolled material cost (components incl. scrap).
LLCSMALLINTLow-level code snapshot at roll time.
COSTDATEDATEWhen 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).

EXPLOSION_WORK — Flattened explosion (global temporary table)

FieldTypeMeaning
TOPITEMCHAR(15)The top item this row belongs to (or '*LLCSCAN' in the LLC pass).
LEVELNOSMALLINTBOM level (top's direct children = 1).
PARENTCHAR(15)Immediate parent (a phantom's identity is kept here even though it gets no row of its own).
COMPONENTCHAR(15)Component at this level.
EXQTYDECIMAL(19,6)Cumulative quantity of COMPONENT per one TOPITEM (product of every scrap-inflated qty down the path).
ISPHANTOMCHAR(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.

VISITED — Cycle-guard path (global temporary table)

FieldTypeMeaning
RUNIDINTEGERLogical-run scope (so concurrent/repeated runs don't collide).
NODECHAR(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.

V_COSTED_BOM — Costed-BOM view

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.

Relationships

E. Operations Runbook ↑ top

E.1 Run the explosion / cost roll-up

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.

  1. Enter / revise BOM edges as needed through the guarded path: 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.
  2. Recompute low-level codes: CALL BOMCOST.PR_LLC_COMPUTE(DATE '<asof>').
  3. Roll all costs: CALL BOMCOST.PR_COST_ROLLUP_ALL(DATE '<asof>', <runbase>) — pass a fresh runbase (e.g. a timestamp-derived integer) each run.
  4. Explode a top item on demand for a pick-list / cost report: CALL BOMCOST.PR_BOM_EXPLODE_TOP('<top>', DATE '<asof>', <runid>), then read EXPLOSION_WORK back in the same session.

Post-checks:

E.2 Reconciling figures

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:

FigureDefinitionWorked 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.

E.3 Failure & re-run rules

Every routine either returns SQLCODE 0 or SIGNALs a specific application SQLSTATE (section F.5). The procedures are designed to be safely re-runnable.

SituationBehaviourAction
Explosion fails partwayPR_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-upMERGE 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 LLCITLLC 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 nothingEXPLOSION_WORK/VISITED are session-scoped.Read the explosion in the same session that ran it, or re-explode in the reading session.
The BEFORE-INSERT cycle trigger 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.

F. Developer Reference ↑ top

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.

F.1 Functions (2)

FN_ACTIVE_QTY (P_PARENT CHAR(15), P_COMPONENT CHAR(15), P_ASOF DATE) RETURNS DECIMAL(19,6)
The scrap-inflated effective quantity-per for a component under a parent as-of a date. Selects the active revision (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.
FN_COST_ROLLUP (P_ITEM CHAR(15), P_ASOF DATE, P_RUNID INTEGER, P_DEPTH INTEGER) RETURNS DECIMAL(19,6)
Recursive scalar function. Purchased (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).

F.2 Procedures (5)

PR_BOM_ADD_LINE (IN P_PARENT, P_COMPONENT CHAR(15), P_QTYPER DECIMAL(13,4), P_SCRAPPCT DECIMAL(7,4), P_PHANTOM CHAR(1), P_EFFFROM DATE, P_EFFTO DATE)
Guarded BOM-edge insert — the app's primary write path. Refuses self-reference (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.
PR_BOM_EXPLODE (IN P_TOPITEM, P_PARENT CHAR(15), P_LEVEL SMALLINT, P_CUMQTY DECIMAL(19,6), P_ASOF DATE, P_RUNID INTEGER)
Recursive stored procedure. Depth > 40 → 90002; parent already VISITED90001; 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.
PR_BOM_EXPLODE_TOP (IN P_TOPITEM CHAR(15), P_ASOF DATE, P_RUNID INTEGER)
Explosion entry point. An 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).
PR_LLC_COMPUTE (IN P_ASOF DATE)
Low-level-code recompute. Resets 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).
PR_COST_ROLLUP_ALL (IN P_ASOF DATE, IN P_RUNBASE INTEGER)
Rolls every manufactured item. Cursors over 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.

F.3 Triggers (5) & the view

TR_BOMLINE_WHEREUSED_INS — AFTER INSERT on BOMLINE, FOR EACH ROW
Inserts (N.COMPONENT, N.PARENT, N.BOMID) into WHEREUSED — the reverse index is grown with every BOM edge.
TR_BOMLINE_WHEREUSED_DEL — AFTER DELETE on BOMLINE, FOR EACH ROW
Deletes the WHEREUSED row for O.BOMID — the reverse index is cascaded out when a BOM edge is removed.
TR_BOMLINE_CYCLE — BEFORE INSERT on BOMLINE, FOR EACH ROW
The trigger form of the cycle guard: refuses 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.
TR_COSTED_BOM_IOI — INSTEAD OF INSERT on V_COSTED_BOM, FOR EACH ROW
Redirects an insert against the costed-BOM view to base BOMLINE, silently dropping the derived read-only COMPCOST.
V_COSTED_BOM — view
BOMLINE LEFT JOIN COSTROLL on component, exposing the BOM columns plus COMPCOST = C.MATCOST.
Platform note (grounded in the driver + SQLPL-SIM-FINDINGS.md). On this engine a BEFORE row trigger observes the table with the NEW row already physically present (SQLPL-PLAT-BOM-01). For 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.

F.4 Recursive SQL-PL patterns

BOMCOST/i deliberately exercises the recursive corners of SQL PL. The reusable patterns:

1. Recursive stored procedure (procedure calls itself)

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;

2. Recursive scalar function (function calls itself)

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);

3. VISITED-based cycle guard (push on entry, pop on exit)

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

4. RESIGNAL across recursion frames

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;

5. SAVEPOINT-based partial-explosion rollback

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;

6. WITH RECURSIVE common table expression (used as the cycle check, and for ad-hoc explosion)

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;
The two explosion forms are intentionally different: 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.

7. MERGE upsert

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.

F.5 Application SQLSTATE table

SQLSTATERaised byMeaning
90001PR_BOM_ADD_LINE / TR_BOMLINE_CYCLE / FN_COST_ROLLUP / PR_BOM_EXPLODECyclic BOM — the edge would close a cycle, or a node is already on the recursion path (VISITED hit).
90002FN_COST_ROLLUP / PR_BOM_EXPLODEMax explosion depth (40) exceeded — a runaway or cycle the VISITED guard did not catch.
90003FN_COST_ROLLUPUnknown item — no ITMAST row for the item being costed (no silent NULL/zero).
90004PR_BOM_ADD_LINE / TR_BOMLINE_CYCLEAn item cannot be its own component (self-reference).
90005FN_ACTIVE_QTY / PR_BOM_EXPLODEScrap percentage ≥ 100% — would divide by zero/negative.

G. Glossary ↑ top

BOM — Bill of Materials
The structured list of components (and their quantities) that make up a manufactured item. Held here in BOMCOST.BOMLINE (parent item → component item, quantity-per, scrap %).
Multi-level BOM explosion
Recursively expanding a parent item into all of its components at every level down to purchased/raw items, accumulating the effective quantity of each. Implemented with a WITH RECURSIVE CTE in PR_BOM_EXPLODE.
Standard-cost roll-up
Computing a manufactured item's total standard cost by summing the (extended) standard costs of its components bottom-up through the BOM levels — the cost analogue of the explosion. Result held in BOMCOST.COSTROLL.
Where-used
The inverse of explosion: for a given component, every parent/assembly that consumes it (directly or indirectly). Materialised in BOMCOST.WHEREUSED.
Level / low-level code
An item's deepest position in any BOM it participates in; roll-up must process items in low-level-code order so a component's cost is final before its parents are rolled.
Quantity-per
The quantity of a component required to make one unit of its immediate parent (before scrap).
Scrap / yield
An allowance for material lost in production; the effective component quantity is grossed up for scrap (see FN_ACTIVE_QTY). A scrap % ≥ 100% is rejected (SQLSTATE 90005).
Extended cost
Component unit standard cost × effective quantity-per; summed across a parent's lines to give the parent's rolled cost.
Cyclic BOM
An invalid structure where an item is (transitively) its own component. Guarded against by TR_BOMLINE_CYCLE / the recursion depth cap (self-reference rejected with SQLSTATE 90004).
WITH RECURSIVE (recursive CTE)
The SQL construct used to walk the BOM tree in a single set-based statement — a seed query unioned with a recursive reference that descends one level per iteration.
SQL PL
DB2-for-i procedural SQL (compound BEGIN…END bodies, variables, control flow, cursors, handlers, SIGNAL). BOMCOST/i's entire logic layer — there is no RPG/COBOL and no 5250 screen.
STRSQL
The interactive SQL entry point (Start SQL) from which an operator drives the explosion / roll-up procedures via CALL.