How to Show a Bill of Materials with Exploded Quantities in Oracle APEX

A recursive WITH query multiplies quantities through every level, a tree and two reports show the structure and parts to order, and a trigger refuses loops.

A bill of materials (BOM) lists the parts that make up a product. A product contains assemblies, the assemblies contain smaller parts, and the same part can appear in several places. To build ten products, you need the quantity of every part at every level, multiplied all the way down.

This guide builds a bill of materials page in Oracle APEX. A recursive WITH query explodes the structure and multiplies the quantities. A tree shows the structure, one report lists every level, and another adds up the parts to order with their cost.

An editable grid maintains the lines, and the database refuses lines that would make a part contain itself.

The post How to Show Hierarchical Data (Manager and Employees) in a Tree in Oracle APEX shows a CONNECT BY tree where every row has one parent. Here a separate lines table links parts, a part can be used in many assemblies, and each level multiplies the quantity of the level above.

The guide uses office furniture as the example. The same steps work for any table where items are made of other items, such as recipes, kits or project work breakdowns.

Step 1: Create the Tables

The bill of materials needs two tables:

  • BOM_PARTS holds every part: finished products, assemblies and purchased components, with the unit of measure and the unit cost.
  • BOM_LINES holds one row per parent and child, with the quantity of the child needed for one parent.

Create the tables and the sample data. An Office Chair has three assemblies and four screws. The Base Assembly holds five Caster Wheels, and each Caster Wheel holds two Wheels. An Office Stool reuses the same Base Assembly:

Example:

create table bom_parts (
  part_id    number generated by default on null as identity,
  part_no    varchar2(20)   not null,
  part_name  varchar2(100)  not null,
  uom        varchar2(5)    default on null 'EA' not null,
  unit_cost  number(10,2),
  constraint bom_parts_pk primary key (part_id),
  constraint bom_parts_part_no_uk unique (part_no),
  constraint bom_parts_uom_ck check (uom in ('EA', 'M', 'KG', 'L')),
  constraint bom_parts_cost_ck check (unit_cost >= 0)
);

create table bom_lines (
  bom_line_id     number generated by default on null as identity,
  parent_part_id  number         not null,
  child_part_id   number         not null,
  quantity        number(10,3)   not null,
  constraint bom_lines_pk primary key (bom_line_id),
  constraint bom_lines_parent_fk foreign key (parent_part_id)
    references bom_parts (part_id),
  constraint bom_lines_child_fk foreign key (child_part_id)
    references bom_parts (part_id),
  constraint bom_lines_parent_child_uk unique (parent_part_id, child_part_id),
  constraint bom_lines_not_self_ck check (parent_part_id <> child_part_id),
  constraint bom_lines_qty_ck check (quantity > 0)
);

create index bom_lines_child_ix on bom_lines (child_part_id);

insert into bom_parts (part_no, part_name) values ('CH-100', 'Office Chair');
insert into bom_parts (part_no, part_name) values ('ST-600', 'Office Stool');
insert into bom_parts (part_no, part_name) values ('SA-200', 'Seat Assembly');
insert into bom_parts (part_no, part_name) values ('BR-300', 'Backrest Assembly');
insert into bom_parts (part_no, part_name) values ('BA-400', 'Base Assembly');
insert into bom_parts (part_no, part_name) values ('CW-420', 'Caster Wheel');
insert into bom_parts (part_no, part_name, unit_cost) values ('CU-210', 'Seat Cushion', 12.50);
insert into bom_parts (part_no, part_name, unit_cost) values ('SP-220', 'Seat Plate', 6.00);
insert into bom_parts (part_no, part_name, uom, unit_cost) values ('MF-310', 'Mesh Fabric', 'M', 9.00);
insert into bom_parts (part_no, part_name, unit_cost) values ('BF-320', 'Backrest Frame', 15.00);
insert into bom_parts (part_no, part_name, unit_cost) values ('SB-410', 'Star Base', 18.00);
insert into bom_parts (part_no, part_name, unit_cost) values ('GL-430', 'Gas Lift', 11.00);
insert into bom_parts (part_no, part_name, unit_cost) values ('WH-421', 'Wheel', 0.90);
insert into bom_parts (part_no, part_name, unit_cost) values ('AX-422', 'Axle Pin', 0.25);
insert into bom_parts (part_no, part_name, unit_cost) values ('SC-M6', 'Screw M6x20', 0.05);

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, l.quantity
  from (select 'CH-100' parent_no, 'SA-200' child_no, 1   quantity from dual union all
        select 'CH-100', 'BR-300', 1   from dual union all
        select 'CH-100', 'BA-400', 1   from dual union all
        select 'CH-100', 'SC-M6',  4   from dual union all
        select 'SA-200', 'CU-210', 1   from dual union all
        select 'SA-200', 'SP-220', 1   from dual union all
        select 'SA-200', 'SC-M6',  4   from dual union all
        select 'BR-300', 'MF-310', 0.8 from dual union all
        select 'BR-300', 'BF-320', 1   from dual union all
        select 'BR-300', 'SC-M6',  6   from dual union all
        select 'BA-400', 'SB-410', 1   from dual union all
        select 'BA-400', 'CW-420', 5   from dual union all
        select 'BA-400', 'GL-430', 1   from dual union all
        select 'CW-420', 'WH-421', 2   from dual union all
        select 'CW-420', 'AX-422', 1   from dual union all
        select 'ST-600', 'BA-400', 1   from dual union all
        select 'ST-600', 'CU-210', 1   from dual union all
        select 'ST-600', 'SC-M6',  3   from dual) l
  join bom_parts p on p.part_no = l.parent_no
  join bom_parts c on c.part_no = l.child_no;

commit;

select p.part_no as parent, c.part_no as child, c.part_name, l.quantity, c.uom
  from bom_lines l
  join bom_parts p on p.part_id = l.parent_part_id
  join bom_parts c on c.part_id = l.child_part_id
 order by p.part_no, c.part_no;

Output:

Table BOM_PARTS created.

Table BOM_LINES created.

Index BOM_LINES_CHILD_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

18 rows inserted.

Commit complete.

PARENT    CHILD     PART_NAME               QUANTITY UOM
_________ _________ ____________________ ___________ ______
BA-400    CW-420    Caster Wheel                   5 EA
BA-400    GL-430    Gas Lift                       1 EA
BA-400    SB-410    Star Base                      1 EA
BR-300    BF-320    Backrest Frame                 1 EA
BR-300    MF-310    Mesh Fabric                  0.8 M
BR-300    SC-M6     Screw M6x20                    6 EA
CH-100    BA-400    Base Assembly                  1 EA
CH-100    BR-300    Backrest Assembly              1 EA
CH-100    SA-200    Seat Assembly                  1 EA
CH-100    SC-M6     Screw M6x20                    4 EA
CW-420    AX-422    Axle Pin                       1 EA
CW-420    WH-421    Wheel                          2 EA
SA-200    CU-210    Seat Cushion                   1 EA
SA-200    SC-M6     Screw M6x20                    4 EA
SA-200    SP-220    Seat Plate                     1 EA
ST-600    BA-400    Base Assembly                  1 EA
ST-600    CU-210    Seat Cushion                   1 EA
ST-600    SC-M6     Screw M6x20                    3 EA

18 rows selected.

The simple rules are declared in the tables:

  • BOM_LINES_PARENT_CHILD_UK allows each child only once under the same parent. A second line for the same screw must change the quantity instead.
  • BOM_LINES_NOT_SELF_CK stops a part from being its own component.
  • BOM_LINES_QTY_CK requires a quantity greater than zero. NUMBER(10,3) allows decimals such as 0.8 meters of fabric.
  • UOM uses DEFAULT ON NULL 'EA', so the inserts only name the unit when it is not "each".
  • BOM_LINES_CHILD_IX indexes the child foreign key. The parent column is already the first column of the unique key.

Step 2: Explode the Structure with Recursive WITH

A recursive WITH query starts with the direct components of the product. Each next round joins the lines of the parts found so far and multiplies the running total by the line quantity:

Example:

with bom (lvl, part_id, part_no, qty_per, total_qty) as (
  select 1, c.part_id, c.part_no, l.quantity, l.quantity
    from bom_lines l
    join bom_parts p on p.part_id = l.parent_part_id
    join bom_parts c on c.part_id = l.child_part_id
   where p.part_no = 'CH-100'
  union all
  select b.lvl + 1, c.part_id, c.part_no, l.quantity, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
    join bom_parts c on c.part_id = l.child_part_id
)
search depth first by part_no set seq
cycle part_id set is_cycle to 'Y' default 'N'
select b.lvl,
       lpad(' ', 2 * (b.lvl - 1)) || b.part_no as part_no,
       p.part_name,
       b.qty_per,
       b.total_qty,
       p.uom
  from bom b
  join bom_parts p on p.part_id = b.part_id
 order by b.seq;

Output:

   LVL PART_NO       PART_NAME               QTY_PER    TOTAL_QTY UOM
______ _____________ ____________________ __________ ____________ ______
     1 BA-400        Base Assembly                 1            1 EA
     2   CW-420      Caster Wheel                  5            5 EA
     3     AX-422    Axle Pin                      1            5 EA
     3     WH-421    Wheel                         2           10 EA
     2   GL-430      Gas Lift                      1            1 EA
     2   SB-410      Star Base                     1            1 EA
     1 BR-300        Backrest Assembly             1            1 EA
     2   BF-320      Backrest Frame                1            1 EA
     2   MF-310      Mesh Fabric                 0.8          0.8 M
     2   SC-M6       Screw M6x20                   6            6 EA
     1 SA-200        Seat Assembly                 1            1 EA
     2   CU-210      Seat Cushion                  1            1 EA
     2   SC-M6       Screw M6x20                   4            4 EA
     2   SP-220      Seat Plate                    1            1 EA
     1 SC-M6         Screw M6x20                   4            4 EA

15 rows selected.

Two clauses make the result usable:

  • SEARCH DEPTH FIRST BY part_no SET seq numbers the rows so that every part is followed by its own components. Ordering by seq gives the tree order.
  • CYCLE part_id SET is_cycle TO 'Y' DEFAULT 'N' stops the query if a part shows up again in its own path. Step 4 shows why that matters.

The Wheel appears with 2 per Caster Wheel, but 10 in total, because the chair has 5 Caster Wheels. The screw SC-M6 appears three times, at different levels.

Step 3: Add Up the Parts to Order

To order parts, add up the total quantity of each purchased part. Purchased parts are the ones that have no lines of their own. The cost is the total quantity times the unit cost:

Example:

with bom (part_id, total_qty) as (
  select l.child_part_id, l.quantity
    from bom_lines l
    join bom_parts p on p.part_id = l.parent_part_id
   where p.part_no = 'CH-100'
  union all
  select l.child_part_id, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
)
cycle part_id set is_cycle to 'Y' default 'N'
select p.part_no,
       p.part_name,
       sum(b.total_qty) as total_qty,
       p.uom,
       p.unit_cost,
       sum(b.total_qty) * p.unit_cost as total_cost
  from bom b
  join bom_parts p on p.part_id = b.part_id
 where not exists (select 1 from bom_lines x where x.parent_part_id = b.part_id)
 group by p.part_no, p.part_name, p.uom, p.unit_cost
 order by p.part_no;

Output:

PART_NO    PART_NAME            TOTAL_QTY UOM       UNIT_COST    TOTAL_COST
__________ _________________ ____________ ______ ____________ _____________
AX-422     Axle Pin                     5 EA             0.25          1.25
BF-320     Backrest Frame               1 EA               15            15
CU-210     Seat Cushion                 1 EA             12.5          12.5
GL-430     Gas Lift                     1 EA               11            11
MF-310     Mesh Fabric                0.8 M                 9           7.2
SB-410     Star Base                    1 EA               18            18
SC-M6      Screw M6x20                 14 EA             0.05           0.7
SP-220     Seat Plate                   1 EA                6             6
WH-421     Wheel                       10 EA              0.9             9

9 rows selected.

The three screw lines add up to 14 (4 + 4 + 6). One chair needs parts for 80.65 in total.

Step 4: Test the Constraints and a Loop

This test tries a part as its own component, a duplicate line and a zero quantity:

Example:

insert into bom_lines (parent_part_id, child_part_id, quantity)
select part_id, part_id, 1 from bom_parts where part_no = 'SA-200';

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 2
  from bom_parts p, bom_parts c
 where p.part_no = 'SA-200' and c.part_no = 'SC-M6';

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 0
  from bom_parts p, bom_parts c
 where p.part_no = 'SA-200' and c.part_no = 'GL-430';

Output:

Error starting at line : 1 File @ b77/20-constraints.sql
In command -
insert into bom_lines (parent_part_id, child_part_id, quantity)
select part_id, part_id, 1 from bom_parts where part_no = 'SA-200'
Error report -
ORA-02290: check constraint (APXSOL.BOM_LINES_NOT_SELF_CK) violated
Help: https://docs.oracle.com/error-help/db/ora-02290/

Error starting at line : 4 File @ b77/20-constraints.sql
In command -
insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 2
  from bom_parts p, bom_parts c
 where p.part_no = 'SA-200' and c.part_no = 'SC-M6'
Error report -
ORA-00001: unique constraint (APXSOL.BOM_LINES_PARENT_CHILD_UK) violated on
table APXSOL.BOM_LINES columns (PARENT_PART_ID, CHILD_PART_ID)
ORA-03301: (ORA-00001 details) row with column values (PARENT_PART_ID:3,
CHILD_PART_ID:15) already exists
Help: https://docs.oracle.com/error-help/db/ora-00001/

Error starting at line : 9 File @ b77/20-constraints.sql
In command -
insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 0
  from bom_parts p, bom_parts c
 where p.part_no = 'SA-200' and c.part_no = 'GL-430'
Error report -
ORA-02290: check constraint (APXSOL.BOM_LINES_QTY_CK) violated
Help: https://docs.oracle.com/error-help/db/ora-02290/

The check constraint only stops the direct case. A longer loop is still accepted: the Caster Wheel is a component of the Base Assembly, and this test makes the Base Assembly a component of the Caster Wheel:

Example:

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'CW-420' and c.part_no = 'BA-400';

with bom (part_id, total_qty) as (
  select l.child_part_id, l.quantity
    from bom_lines l
    join bom_parts p on p.part_id = l.parent_part_id
   where p.part_no = 'CH-100'
  union all
  select l.child_part_id, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
)
select count(*) from bom;

with bom (part_id, part_no, total_qty) as (
  select c.part_id, c.part_no, l.quantity
    from bom_lines l
    join bom_parts p on p.part_id = l.parent_part_id
    join bom_parts c on c.part_id = l.child_part_id
   where p.part_no = 'CH-100'
  union all
  select c.part_id, c.part_no, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
    join bom_parts c on c.part_id = l.child_part_id
)
cycle part_id set is_cycle to 'Y' default 'N'
select part_no, total_qty, is_cycle
  from bom
 where is_cycle = 'Y';

rollback;

Output:

1 row inserted.

Error starting at line : 6 File @ b77/21-loop.sql
In command -
with bom (part_id, total_qty) as (
  select l.child_part_id, l.quantity
    from bom_lines l
    join bom_parts p on p.part_id = l.parent_part_id
   where p.part_no = 'CH-100'
  union all
  select l.child_part_id, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
)
select count(*) from bom
Error report -
ORA-32044: cycle detected while executing recursive WITH query
Help: https://docs.oracle.com/error-help/db/ora-32044/

PART_NO       TOTAL_QTY IS_CYCLE
__________ ____________ ___________
BA-400                5 Y

Rollback complete.

The insert was saved. Without the CYCLE clause, the explosion then fails with ORA-32044. With the CYCLE clause, the query runs and flags the repeated part. The test rolled back the loop.

Step 5: Stop Loops with a Trigger

A loop can run through any number of lines, so no constraint can express it. This is a case where a trigger is the right tool. After each insert or update on BOM_LINES, the trigger walks every line down to its leaves and counts paths that come back to their start:

Example:

create or replace trigger bom_lines_no_loop_trg
for insert or update of parent_part_id, child_part_id on bom_lines
compound trigger

  before statement is
  begin
    lock table bom_lines in exclusive mode;
  end before statement;

  after statement is
    l_loops pls_integer;
  begin
    with walk (top_part_id, part_id) as (
      select parent_part_id, child_part_id
        from bom_lines
      union all
      select w.top_part_id, l.child_part_id
        from walk w
        join bom_lines l on l.parent_part_id = w.part_id
    )
    cycle part_id set is_cycle to 'Y' default 'N'
    select count(*)
      into l_loops
      from walk
     where part_id = top_part_id;

    if l_loops > 0 then
      raise_application_error(-20001,
        'This line would make a part contain itself. Choose another component.');
    end if;
  end after statement;

end bom_lines_no_loop_trg;
/

Output:

Trigger BOM_LINES_NO_LOOP_TRG compiled

The trigger refuses a loop through an insert and through an update, and it still accepts a normal line:

Example:

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'CW-420' and c.part_no = 'CH-100';

update bom_lines
   set child_part_id = (select part_id from bom_parts where part_no = 'BA-400')
 where parent_part_id = (select part_id from bom_parts where part_no = 'CW-420')
   and child_part_id = (select part_id from bom_parts where part_no = 'AX-422');

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 2
  from bom_parts p, bom_parts c
 where p.part_no = 'CW-420' and c.part_no = 'SC-M6';

select count(*) as lines from bom_lines;

rollback;

Output:

Error starting at line : 1 File @ b77/31-loop-test.sql
In command -
insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'CW-420' and c.part_no = 'CH-100'
Error at Command Line : 1 Column : 13 File @ b77/31-loop-test.sql
Error report -
SQL Error: ORA-20001: This line would make a part contain itself. Choose another component.
ORA-06512: at "APXSOL.BOM_LINES_NO_LOOP_TRG", line 26
ORA-04088: error during execution of trigger 'APXSOL.BOM_LINES_NO_LOOP_TRG'
Help: https://docs.oracle.com/error-help/db/ora-20001/

Error starting at line : 6 File @ b77/31-loop-test.sql
In command -
update bom_lines
   set child_part_id = (select part_id from bom_parts where part_no = 'BA-400')
 where parent_part_id = (select part_id from bom_parts where part_no = 'CW-420')
   and child_part_id = (select part_id from bom_parts where part_no = 'AX-422')
Error at Command Line : 6 Column : 8 File @ b77/31-loop-test.sql
Error report -
SQL Error: ORA-20001: This line would make a part contain itself. Choose another component.
ORA-06512: at "APXSOL.BOM_LINES_NO_LOOP_TRG", line 26
ORA-04088: error during execution of trigger 'APXSOL.BOM_LINES_NO_LOOP_TRG'
Help: https://docs.oracle.com/error-help/db/ora-20001/

1 row inserted.

   LINES
________
      19

Rollback complete.

Why the Trigger Locks the Table

In a first version, the trigger had only the AFTER STATEMENT check, without the lock. Two sessions then added two lines that form a loop only together. Session A makes the Star Base a component of the Gas Lift and waits 6 seconds before it commits:

Example:

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'GL-430' and c.part_no = 'SB-410';

exec dbms_session.sleep(6)

commit;

Output:

1 row inserted.

PL/SQL procedure successfully completed.

Commit complete.

Session B starts 2 seconds later and makes the Gas Lift a component of the Star Base:

Example:

insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'SB-410' and c.part_no = 'GL-430';

commit;

Output:

1 row inserted.

Commit complete.

Each session checked the data it could see, and neither saw the other's line. Both lines were saved, and the loop was in the table:

Example:

select p.part_no as parent, c.part_no as child
  from bom_lines l
  join bom_parts p on p.part_id = l.parent_part_id
  join bom_parts c on c.part_id = l.child_part_id
 where p.part_no in ('GL-430', 'SB-410');

Output:

PARENT    CHILD
_________ _________
GL-430    SB-410
SB-410    GL-430

The two rows were deleted. The BEFORE STATEMENT part of the compound trigger now locks BOM_LINES in exclusive mode, so changes to the structure run one after another. The same two sessions again; session A:

Output:

1 row inserted.

PL/SQL procedure successfully completed.

Commit complete.

Session B waited about 4 seconds for session A to commit, then saw its line and was refused:

Output:

Error starting at line : 1 File @ b77/32-race-b.sql
In command -
insert into bom_lines (parent_part_id, child_part_id, quantity)
select p.part_id, c.part_id, 1
  from bom_parts p, bom_parts c
 where p.part_no = 'SB-410' and c.part_no = 'GL-430'
Error at Command Line : 1 Column : 13 File @ b77/32-race-b.sql
Error report -
SQL Error: ORA-20001: This line would make a part contain itself. Choose another component.
ORA-06512: at "APXSOL.BOM_LINES_NO_LOOP_TRG", line 26
ORA-04088: error during execution of trigger 'APXSOL.BOM_LINES_NO_LOOP_TRG'
Help: https://docs.oracle.com/error-help/db/ora-20001/

Commit complete.

The lock lasts until the commit, so only one user can change the structure at a time. For BOM maintenance, where changes are rare, that is acceptable.

Step 6: Create the Error Handling Function

Without help, APEX shows ORA-00001 with the table and column names, or ORA-20001 in front of the trigger message. This page error handling function maps the constraints to plain messages and removes the ORA-20001 prefix:

Example:

create or replace function bom_error_handler (
  p_error in apex_error.t_error
) return apex_error.t_error_result
is
  l_result apex_error.t_error_result;
begin
  l_result := apex_error.init_error_result(p_error => p_error);

  if p_error.ora_sqlcode = -20001 then
    l_result.message := apex_error.get_first_ora_error_text(p_error => p_error);
    l_result.additional_info := null;
  elsif p_error.ora_sqlcode in (-1, -2290) then
    case apex_error.extract_constraint_name(p_error => p_error)
      when 'BOM_LINES_PARENT_CHILD_UK' then
        l_result.message := 'This component is already in the list. Change its quantity instead.';
      when 'BOM_LINES_NOT_SELF_CK' then
        l_result.message := 'A part cannot be a component of itself.';
      when 'BOM_LINES_QTY_CK' then
        l_result.message := 'Quantity must be greater than zero.';
      else
        null;
    end case;
    l_result.additional_info := null;
  end if;

  return l_result;
end bom_error_handler;
/

show errors

Output:

Function BOM_ERROR_HANDLER compiled

No errors.

APEX_ERROR.GET_FIRST_ORA_ERROR_TEXT returns the trigger message without the ORA-20001 prefix and without the ORA-06512 lines.

Step 7: Create the Bill of Materials Page

In App Builder, open the application and click Create Page. Select the Blank Page tile:

Create a Page wizard with the Blank Page tile marked with a red box
Select the Blank Page type.

Enter 740 as the Page Number and Bill of Materials as the Name. Turn off Use Breadcrumb, keep Use Navigation on, enter fa-sitemap as the Icon and click Create Page:

Create Blank Page screen with Page Number 740, Name Bill of Materials, Use Breadcrumb off, Use Navigation on and Icon fa-sitemap marked with red boxes
The page number, name and navigation entry.

In Page Designer, click the page node Page 740: Bill of Materials. In the Error Handling group, enter bom_error_handler as the Error Handling Function Name:

Page 740 properties with Error Handling Function Name bom_error_handler marked with a red box
The handler runs only for this page.

Step 8: Add the Part Region and Its Items

Create a region named Part with the Type Static Content:

Region Part with Name and Type Static Content marked with red boxes
The region holds the part and the quantity to build.

Add a page item P740_PART_ID to the Part region with these settings:

  • Type: Select List
  • Label: Part
  • Settings > Page Action on Selection: Submit Page
  • List of Values > Type: SQL Query
  • Display Null Value: off

The SQL Query lists all parts, so an assembly can be opened on its own too:

Example:

select part_no || ' - ' || part_name as display_value,
       part_id as return_value
  from bom_parts
 order by part_no
Page item P740_PART_ID with Name, Type Select List, Label Part, Page Action on Selection Submit Page, LOV Type SQL Query, the query and Display Null Value off marked with red boxes
Choosing a part submits the page.

In the Default group, set the Type to SQL Query (return single value). The query picks the first finished product: a part that has lines but is not used in any other part:

Example:

select min(p.part_id) keep (dense_rank first order by p.part_no)
  from bom_parts p
 where exists (select 1 from bom_lines l where l.parent_part_id = p.part_id)
   and not exists (select 1 from bom_lines l where l.child_part_id = p.part_id)
Default Type SQL Query return single value and the query marked with a red box
The page opens with the Office Chair.

Add a page item P740_BUILD_QTY with the Type Number Field and the Label Quantity to Build. Set Minimum Value to 1 and Maximum Value to 10000:

Page item P740_BUILD_QTY with Name, Type Number Field, Label Quantity to Build, Minimum Value 1 and Maximum Value 10000 marked with red boxes
The number of products to build.

Turn on Value Required and set the Default to the Static value 1:

Value Required on and Default Type Static with Static Value 1 marked with red boxes
Without a value, the page shows the quantities for one product.

Add a button SHOW with the Label Show to the Part region, slot Region Body. Turn on Hot and keep the Action Submit Page:

Button SHOW with Button Name, Label Show, Region Part, Slot Region Body, Hot on and Action Submit Page marked with red boxes
Show recalculates the quantities.

Step 9: Add the Structure Tree

Create a region named Structure with the Type Tree. Set the Source Type to SQL Query and Page Items to Submit to P740_PART_ID,P740_BUILD_QTY. The query is the explosion from Step 2. It starts with the selected part itself as the root and returns the columns the tree needs:

Example:

with bom (lvl, part_id, part_no, qty_per, total_qty) as (
  select 1, part_id, part_no, 1, 1
    from bom_parts
   where part_id = :P740_PART_ID
  union all
  select b.lvl + 1, c.part_id, c.part_no, l.quantity, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
    join bom_parts c on c.part_id = l.child_part_id
)
search depth first by part_no set seq
cycle part_id set is_cycle to 'Y' default 'N'
select case
         when exists (select 1 from bom_lines x where x.parent_part_id = b.part_id)
         then 1 else 0
       end as status,
       b.lvl,
       b.part_no || ' ' || p.part_name ||
         case
           when b.lvl = 1
           then ' - build ' || :P740_BUILD_QTY
           else ' - ' || rtrim(to_char(b.qty_per, 'FM999999990.999'), '.') || ' per parent, ' ||
                rtrim(to_char(b.total_qty * :P740_BUILD_QTY, 'FM999999990.999'), '.') ||
                ' ' || p.uom || ' in total'
         end as title,
       case
         when exists (select 1 from bom_lines x where x.parent_part_id = b.part_id)
         then 'fa-cubes' else 'fa-cube'
       end as icon,
       b.seq as value,
       null as tooltip,
       null as link
  from bom b
  join bom_parts p on p.part_id = b.part_id
 order by b.seq
Region Structure with Name, Type Tree, Source Type SQL Query, the query and Page Items to Submit marked with red boxes
The tree query.

The columns follow the tree rules:

  • STATUS is 1 (expanded) for parts with components and 0 for leaves.
  • LVL is the level from the recursive query; the root is level 1.
  • TITLE shows the quantity per parent and the total for the quantity to build.
  • VALUE is the depth-first number SEQ. A part used twice gets two different values.
  • ICON is fa-cubes for assemblies and fa-cube for leaves.

On the Attributes tab, set Node Label Column to TITLE, Node Value Column to VALUE, Hierarchy to Not Computed, Node Status Column to STATUS, Hierarchy Level Column to LVL, Link Column to LINK and Icon CSS Class Column to ICON:

Tree attributes Node Label Column TITLE, Node Value Column VALUE, Hierarchy Not Computed, Node Status Column STATUS, Hierarchy Level Column LVL, Link Column LINK and Icon CSS Class Column ICON marked with red boxes
Not Computed uses the order and levels of the query.

Not Computed tells APEX to use the order and levels of the query as they are. The recursive query already returns them, together with the multiplied quantities.

Step 10: Add the Exploded Report

Create a region named Exploded Bill of Materials with the Type Classic Report. Set Page Items to Submit to P740_PART_ID,P740_BUILD_QTY and use this query:

Example:

with bom (lvl, part_id, part_no, qty_per, total_qty) as (
  select 1, l.child_part_id, c.part_no, l.quantity, l.quantity
    from bom_lines l
    join bom_parts c on c.part_id = l.child_part_id
   where l.parent_part_id = :P740_PART_ID
  union all
  select b.lvl + 1, c.part_id, c.part_no, l.quantity, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
    join bom_parts c on c.part_id = l.child_part_id
)
search depth first by part_no set seq
cycle part_id set is_cycle to 'Y' default 'N'
select b.lvl as lvl,
       lpad(b.part_no, length(b.part_no) + 4 * (b.lvl - 1), unistr('\00a0')) as part_no,
       p.part_name,
       rtrim(to_char(b.qty_per, 'FM999999990.999'), '.') as qty_per,
       rtrim(to_char(b.total_qty * :P740_BUILD_QTY, 'FM999999990.999'), '.') as total_qty,
       p.uom
  from bom b
  join bom_parts p on p.part_id = b.part_id
 order by b.seq
Region Exploded Bill of Materials with Name, Type Classic Report, Source Type SQL Query, the query and Page Items to Submit marked with red boxes
One row per part and level.

LPAD with the non-breaking space unistr('\00a0') indents each part number by its level. Normal spaces would be collapsed by the browser. TO_CHAR with the format FM999999990.999 shows 0.8 instead of .8, and RTRIM removes the trailing point of whole numbers.

Set the column headings to Level, Part No, Part Name, Qty per Parent, Total Qty and UOM, and right-align the number columns. On the Attributes tab, set Number of Rows to 50 and keep Pagination at No Pagination. Enter This part has no components. as the When No Data Found message:

Classic report attributes Number of Rows 50 and Pagination Type No Pagination marked with red boxes
The whole structure shows on one page.

Step 11: Add the Quantities to Order Report

Create a classic report region named Quantities to Order with the same Page Items to Submit. The query is the total from Step 3, multiplied by the quantity to build. GROUPING SETS adds a Total row with the cost of everything:

Example:

with bom (part_id, total_qty) as (
  select l.child_part_id, l.quantity
    from bom_lines l
   where l.parent_part_id = :P740_PART_ID
  union all
  select l.child_part_id, b.total_qty * l.quantity
    from bom b
    join bom_lines l on l.parent_part_id = b.part_id
)
cycle part_id set is_cycle to 'Y' default 'N'
select case when grouping(p.part_no) = 1 then 'Total' else p.part_no end as part_no,
       p.part_name,
       case when grouping(p.part_no) = 0
            then rtrim(to_char(sum(b.total_qty) * :P740_BUILD_QTY, 'FM999999990.999'), '.')
       end as qty_to_order,
       p.uom,
       p.unit_cost,
       sum(b.total_qty * p.unit_cost) * :P740_BUILD_QTY as total_cost
  from bom b
  join bom_parts p on p.part_id = b.part_id
 where not exists (select 1 from bom_lines x where x.parent_part_id = b.part_id)
 group by grouping sets ((p.part_no, p.part_name, p.uom, p.unit_cost), ())
 order by grouping(p.part_no), p.part_no
Region Quantities to Order with Name, Type Classic Report, Source Type SQL Query, the query and Page Items to Submit marked with red boxes
Purchased parts with quantities and cost.

Set the Format Mask of the UNIT_COST and TOTAL_COST columns to FML999G990D00:

Column UNIT_COST with Format Mask FML999G990D00 marked with a red box
Costs show with the currency symbol.

Step 12: Add the Components Grid

Create a region named Components of the Selected Part with the Type Interactive Grid. Set the Source Type to Table / View, Table Name to BOM_LINES, Where Clause to parent_part_id = :P740_PART_ID and Page Items to Submit to P740_PART_ID:

Interactive grid region with Name, Type, Table Name BOM_LINES, Where Clause parent_part_id = :P740_PART_ID and Page Items to Submit P740_PART_ID marked with red boxes
The grid shows the direct components of the selected part.

On the Attributes tab, turn on Edit and allow Add Row, Update Row and Delete Row:

Edit Enabled on with Allowed Operations Add Row, Update Row and Delete Row marked with a red box
Users can add, change and remove lines.

Set the PARENT_PART_ID column to Type Hidden with the Default Type Item and the Item P740_PART_ID. New lines then belong to the selected part:

Column PARENT_PART_ID with Type Hidden and Default Type Item, Item P740_PART_ID marked with red boxes
The parent comes from the selected part.

Set CHILD_PART_ID to Type Select List with the Heading Component. Use this SQL Query and the Null Display Value - Select Component -:

Example:

select part_no || ' - ' || part_name as display_value, part_id as return_value from bom_parts order by part_no
Column CHILD_PART_ID with Type Select List, Heading Component, LOV Type SQL Query, the query, Display Null Value on and Null Display Value marked with red boxes
The component to add.

Set QUANTITY to Type Number Field with the Heading Quantity and turn on Value Required. The check constraint refuses zero and negative values:

Column QUANTITY with Type Number Field, Heading Quantity and Value Required on marked with red boxes
The quantity per parent.

Creating the grid also added the process Components - Save Interactive Grid Data with the Type Interactive Grid - Automatic Row Processing (DML) and the grid as its Editable Region:

Process with Name Components - Save Interactive Grid Data, Type Interactive Grid Automatic Row Processing DML and Editable Region marked with red boxes
The process saves the grid rows.

Add a branch named Back to Bill of Materials with the Target Page 740, so the Show button and the select list return to the same page:

Branch Back to Bill of Materials with Type Page or URL Redirect and Target Page 740 marked with red boxes
The branch after submit.

Step 13: Add the Dynamic Actions

The tree opened with only the first level expanded in a first test, even with STATUS 1 on every assembly. Create a dynamic action named Expand Structure with the Event Page Load:

Dynamic action Expand Structure with Event Page Load marked with red boxes
Runs once when the page loads.

Set its True action to Expand Tree with the Selection Type Region and the Region Structure:

Action Expand Tree with Selection Type Region and Region Structure marked with red boxes
Opens every level of the tree.

The grid saves through Ajax, so the tree and reports do not change by themselves. Create a dynamic action named Refresh After Save with the Event Save [Interactive Grid], the Selection Type Region and the Region Components of the Selected Part:

Dynamic action Refresh After Save with Event Save Interactive Grid, Selection Type Region and Region Components of the Selected Part marked with red boxes
Fires after the grid has saved.

Add three True actions of the Type Refresh, for the regions Structure, Exploded Bill of Materials and Quantities to Order:

Action Refresh with Selection Type Region and Region Structure marked with red boxes
The first Refresh action; the other two refresh the reports.

After a refresh, the tree was collapsed again. An Expand Tree action right after the Refresh actions did not help, because it ran before the new tree arrived. Create a dynamic action named Expand After Refresh with the Event After Refresh on the Region Structure instead:

Dynamic action Expand After Refresh with Event After Refresh, Selection Type Region and Region Structure marked with red boxes
Fires when the new tree is on the page.

Give it a True action Expand Tree on the Region Structure, the same as in Expand Structure.

Test the Bill of Materials

The Office Chair

Sign in and open Bill of Materials. The page opens with the Office Chair, and the tree shows every level with the quantity per parent and in total:

Bill of Materials page with Part CH-100 Office Chair and the expanded Structure tree showing Base Assembly, Caster Wheel, Wheel 10 EA in total and the other parts
The structure of one chair.

The exploded report lists the same rows with indented part numbers:

Exploded Bill of Materials report with levels 1 to 3, indented part numbers, Qty per Parent and Total Qty
The Wheel needs 2 per Caster Wheel and 10 in total.

Ten Chairs

Enter 10 as the Quantity to Build and click Show. Every total is multiplied by 10:

Quantity to Build 10 marked with a red box and the tree showing CH-100 Office Chair build 10 and Wheel 100 EA in total
100 wheels for 10 chairs.

The Quantities to Order report adds the three screw lines to 140 and shows a total cost of 806.50, ten times the 80.65 from Step 3:

Quantities to Order report for 10 chairs with the rows SC-M6 140 and Total 806.50 marked with red boxes
The parts to order for 10 chairs.

A Quantity to Build of 0 is refused by the number field settings:

Error Quantity to Build must be a number between 1 and 10000 shown in the notification and below the field
The minimum value of the number field.

A Shared Assembly

Select ST-600 - Office Stool. The stool uses the same Base Assembly as the chair, so the tree shows the same caster wheels and wheels under it:

Part ST-600 Office Stool marked with a red box and the tree with Base Assembly, Caster Wheel, Seat Cushion and 3 screws
The Base Assembly is defined once and used twice.
Quantities to Order for the stool with Wheel 10, Screw 3 and Total 51.90
The parts for one stool.

Lines That Are Refused

Select CW-420 - Caster Wheel. In the grid, click Add Row, choose CH-100 - Office Chair with the quantity 1 and click Save. The chair contains the caster wheel, so the trigger refuses the line:

Error This line would make a part contain itself. Choose another component. with the new grid row CH-100 Office Chair marked with a red box
The loop check from the trigger.

Adding WH-421 - Wheel a second time shows the message for the unique key:

Error This component is already in the list. Change its quantity instead. with the duplicate grid row WH-421 Wheel marked with a red box
One line per parent and child.

Adding CW-420 - Caster Wheel to itself shows the message for the check constraint:

Error A part cannot be a component of itself. with the grid row CW-420 Caster Wheel marked with a red box
The direct case is caught by the check constraint.

A quantity of 0 shows the message for the quantity check:

Error Quantity must be greater than zero. with the grid row SC-M6 quantity 0 marked with a red box
The quantity must be positive.

A Line That Is Saved

Add SC-M6 - Screw M6x20 with the quantity 2 to the Caster Wheel and click Save. The grid saves, and the tree and both reports refresh without a page reload:

Changes saved message, the new tree node SC-M6 Screw 2 per parent and the new grid row SC-M6 quantity 2 marked with red boxes
The tree, the reports and the grid after the save.

Back on the Office Chair, the screws to order went up from 14 to 24, because 5 caster wheels need 2 screws each. The total cost rose by 0.50 to 81.15:

Quantities to Order for the chair with SC-M6 24 and Total 81.15 marked with red boxes
A change deep in the structure reaches every product that uses it.

The test line was deleted afterwards. A final check shows the 18 sample lines and no loops:

Example:

select count(*) as bom_lines from bom_lines;

with walk (top_part_id, part_id) as (
  select parent_part_id, child_part_id
    from bom_lines
  union all
  select w.top_part_id, l.child_part_id
    from walk w
    join bom_lines l on l.parent_part_id = w.part_id
)
cycle part_id set is_cycle to 'Y' default 'N'
select count(*) as loops
  from walk
 where part_id = top_part_id;

Output:

   BOM_LINES
____________
          18

   LOOPS
________
       0

Things to Know

  • A recursive WITH query carries a running product of the quantities from level to level, so every row knows its total for one product.
  • Keep the CYCLE clause in every BOM query. If a loop ever reaches the table, the page still opens instead of failing with ORA-32044.
  • A loop check that only reads committed data is not enough. Two sessions saved a loop together until the trigger locked the table first.
  • The trigger checks the whole table after each statement. With the 18 lines of this example there was no noticeable delay. For very large structures, check only the paths below the changed lines.
  • Use Hierarchy Not Computed when the query already returns the tree order and levels. Give each node a unique value, because the same part can appear more than once.
  • A tree region in APEX 26.1 opened with only the first level expanded. Expand Tree on Page Load and on After Refresh of the tree shows every level.
  • The page has no authorization scheme in this example. In a real system, limit who can edit the grid, for example with the Edit Authorization settings of the grid.

Related Guides

Conclusion

A bill of materials in Oracle APEX is one lines table and one recursive WITH query. The query multiplies the quantities from level to level, SEARCH DEPTH FIRST gives the tree order, and the CYCLE clause protects every query from loops. The same query feeds a tree, an exploded report and the totals to order.

Constraints stop duplicates, self-references and zero quantities, and a compound trigger refuses longer loops, also when two users save at the same moment. An editable grid maintains the lines, a page error handling function shows clear messages, and dynamic actions refresh the tree and reports after each save.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted