2

I have a recursive function below that works very well but I have now found that some of the data is not unique and I need a way to handle it.

FUNCTION calc_cost (model_no_         NUMBER,
                    revision_         NUMBER,
                    sequence_no_   IN NUMBER,
                    currency_      IN VARCHAR2)
    RETURN NUMBER
IS
    qty_    NUMBER := 0;
    cost_   NUMBER := 0;
BEGIN
    SELECT NVL (new_qty, qty), purch_cost
      INTO qty_, cost_
      FROM prod_conf_cost_struct_clv
     WHERE model_no = model_no_
       AND revision = revision_
       AND sequence_no = sequence_no_
       AND (purch_curr = currency_
         OR purch_curr IS NULL);

    IF cost_ IS NULL
    THEN
        SELECT SUM (calc_cost (model_no,
                               revision,
                               sequence_no,
                               purch_curr))
          INTO cost_
          FROM prod_conf_cost_struct_clv
         WHERE model_no = model_no_
           AND revision = revision_
           AND (purch_curr = currency_
             OR purch_curr IS NULL)
           AND part_no IN (SELECT component_part
                             FROM prod_conf_cost_struct_clv
                            WHERE model_no = model_no_
                              AND revision = revision_
                              AND sequence_no = sequence_no_);
    END IF;

    RETURN qty_ * cost_;
EXCEPTION
    WHEN NO_DATA_FOUND
    THEN
        RETURN 0;
END calc_cost;

The following criterion is where this function is failing ...part_no in (select component_part....

Sample data:

rownum., model_no, revision, sequence_no, part_no, component_part, level, cost, purch_curr, qty

 1. 62, 1, 00, XXX, ABC, 1, null, null, 1
 2. 62, 1, 10, ABC, 123, 2, null, null, 1
 3. 62, 1, 20, 123, DEF, 3, null, null, 1
 4. 62, 1, 30, DEF, 456, 4, 100, GBP, 1
 5. 62, 1, 40, DEF, 789, 4, 50, GBP, 1
 6. 62, 1, 50, DEF, 024, 4, 20, GBP, 1
 7. 62, 1, 60, ABC, 356, 2, null, null, 2
 8. 62, 1, 70, 356, DEF, 3, null, null, 3
 9. 62, 1, 80, DEF, 456, 4, 100, GBP, 1
 10. 62, 1, 90, DEF, 789, 4, 50, EUR, 1
 11. 62, 1, 100, DEF, 024, 4, 20, GBP, 1

If I was to pass the following values into the function parameters: model_no, revision, sequence_no (ignore currency as it is not relevant to the issue):

62, 1, 20

I want it to summarize rows 4-6 ONLY = 170, however it is summarizing rows 4-6 AND 9-11 = 340.

Ultimately this function will be used in the SQL query below:

    SELECT LEVEL,
           SYS_CONNECT_BY_PATH (sequence_no, '->') PATH,
           calc_cost (model_no,
                      revision,
                      sequence_no,
                      'GBP')
               total_gbp
      FROM prod_conf_cost_struct_clv
     WHERE model_no = 62
       AND revision = 1
CONNECT BY PRIOR component_part = part_no
       AND PRIOR model_no = 62
       AND PRIOR revision = 1
START WITH sequence_no = 20
  ORDER BY sequence_no

As you can see this would also introduce the issue of component_part = part_no.

UPDATE

Further to the answers provided, I thought I would expand the original question so that the currency and qty elements are dealt with as well. I have updated the sample data to include currency and qty.

If I was to pass the following values into the function parameters: model_no, revision, sequence_no, currency:

Input: 62, 1, 70, EUR 
Expected Cost Output: 150

Input: 62, 1, 60, EUR 
Expected Cost Output: 300

Input: 62, 1, 60, GBP
Expected Cost Output: 720

Any assistance would be most appreciated.

Thanks in advance.

1
  • Thanks for the tidy up @Bob Commented Mar 27, 2019 at 21:55

3 Answers 3

2
+50

Note: if you have trouble running the MATCH_RECOGNIZE stuff, it may be because you are running a (not too) old version of SQL*Developer. Try the latest version or use SQL*Navigator, TOAD, or SQL*Plus instead. The problem is the "?" character, which confuse SQL*Developer, since that is the character JDBC uses for bind variables.

You have got a data model problem. Namely, child records in your prod_conf_cost_struct_cvl table are not explicitly linked to their parent rows. This is why the "DEF" subassembly is causing problems. Without an explicit linkage, there is no way to compute the data cleanly.

You should correct this data model and add a parent_sequence_no to each record, so that (for example) you can tell that sequence_no 80 is a child of sequence_no 70, and not a child of sequence_no 20.

However, since I cannot assume you've got time or authority to change your data model, I'll answer the question with the data model as is.

First of all, let's add QTY and PURCH_CURR to your sample data.

with prod_conf_cost_struct_clv ( model_no, revision, sequence_no, part_no, component_part, lvl, cost, qty, purch_curr ) as
( 
SELECT 62, 1, 00, 'XXX', 'ABC', 1, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 10, 'ABC', '123', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 20, '123', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 30, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 40, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 50, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 60, 'ABC', '356', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 70, '356', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 80, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 90, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 100, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL )
select * from prod_conf_cost_struct_clv;
+----------+----------+-------------+---------+----------------+-----+------+-----+------------+
| MODEL_NO | REVISION | SEQUENCE_NO | PART_NO | COMPONENT_PART | LVL | COST | QTY | PURCH_CURR |
+----------+----------+-------------+---------+----------------+-----+------+-----+------------+
|       62 |        1 |           0 | XXX     | ABC            |   1 |      |   1 | GBP        |
|       62 |        1 |          10 | ABC     | 123            |   2 |      |   1 | GBP        |
|       62 |        1 |          20 | 123     | DEF            |   3 |      |   1 | GBP        |
|       62 |        1 |          30 | DEF     | 456            |   4 |  100 |   1 | GBP        |
|       62 |        1 |          40 | DEF     | 789            |   4 |   50 |   1 | GBP        |
|       62 |        1 |          50 | DEF     | 024            |   4 |   20 |   1 | GBP        |
|       62 |        1 |          60 | ABC     | 356            |   2 |      |   1 | GBP        |
|       62 |        1 |          70 | 356     | DEF            |   3 |      |   1 | GBP        |
|       62 |        1 |          80 | DEF     | 456            |   4 |  100 |   1 | GBP        |
|       62 |        1 |          90 | DEF     | 789            |   4 |   50 |   1 | GBP        |
|       62 |        1 |         100 | DEF     | 024            |   4 |   20 |   1 | GBP        |
+----------+----------+-------------+---------+----------------+-----+------+-----+------------+

NOTE: you don't show how multiple currencies would be represented in your test data, so my handling of that issue in this answer may be incorrect.

OK, so the first real thing we need to do is figure out the value of parent_sequence_no (which really should be in your table - see above). Since it's not in your table, we need to compute it. We will compute it as the sequence_no of the row having the highest sequence_no that is less than the current row and having a level (which I called lvl to avoid using the Oracle keyword) that is one less than the current row.

To find this value efficiently, we can use MATCH_RECOGNIZE functionality to describe what the parent row for each child should look like.

We will call the result set with this new parent_sequence_no column corrected_hierarchy.

with prod_conf_cost_struct_clv ( model_no, revision, sequence_no, part_no, component_part, lvl, cost, qty, purch_curr ) as
( 
SELECT 62, 1, 00, 'XXX', 'ABC', 1, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 10, 'ABC', '123', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 20, '123', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 30, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 40, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 50, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 60, 'ABC', '356', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 70, '356', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 80, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 90, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 100, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL )
-- Step 1: correct for your data model problem, which is the fact that child rows
-- (e.g., operations 30-50) are not *explicitly* linked to their parent rows (e.g.,
-- operation 20)
, corrected_hierarchy ( model_no, revision, parent_sequence_no, sequence_no, part_no, component_part, lvl, cost, qty, purch_curr ) AS
(
SELECT *
FROM   prod_conf_cost_struct_clv c
MATCH_RECOGNIZE (
  PARTITION BY model_no, revision
  ORDER BY sequence_no desc
  MEASURES (P.sequence_no) AS parent_sequence_no,
           c.sequence_no AS sequence_no, c.part_no as part_no, c.component_part as component_part, c.lvl as lvl, c.cost as cost, c.qty as qty, c.purch_curr as purch_curr
  ONE ROW PER MATCH
  AFTER MATCH SKIP TO NEXT ROW
  -- C => child row
  -- S* => zero or more siblings or children of siblings that might be 
  --           between child and its parent
  -- P? => parent row, which may not exist (e.g., for the root operation)
  PATTERN (C S* P?)
  DEFINE
    C AS 1=1,
    S AS S.lvl >= C.lvl,
    P AS P.lvl = C.lvl - 1 AND P.component_part = C.part_no
)
ORDER BY model_no, revision, sequence_no )
SELECT * FROM corrected_hierarchy;
+----------+----------+--------------------+-------------+---------+----------------+-----+------+-----+------------+
| MODEL_NO | REVISION | PARENT_SEQUENCE_NO | SEQUENCE_NO | PART_NO | COMPONENT_PART | LVL | COST | QTY | PURCH_CURR |
+----------+----------+--------------------+-------------+---------+----------------+-----+------+-----+------------+
|       62 |        1 |                    |           0 | XXX     | ABC            |   1 |      |   1 | GBP        |
|       62 |        1 |                  0 |          10 | ABC     | 123            |   2 |      |   1 | GBP        |
|       62 |        1 |                 10 |          20 | 123     | DEF            |   3 |      |   1 | GBP        |
|       62 |        1 |                 20 |          30 | DEF     | 456            |   4 |  100 |   1 | GBP        |
|       62 |        1 |                 20 |          40 | DEF     | 789            |   4 |   50 |   1 | GBP        |
|       62 |        1 |                 20 |          50 | DEF     | 024            |   4 |   20 |   1 | GBP        |
|       62 |        1 |                  0 |          60 | ABC     | 356            |   2 |      |   1 | GBP        |
|       62 |        1 |                 60 |          70 | 356     | DEF            |   3 |      |   1 | GBP        |
|       62 |        1 |                 70 |          80 | DEF     | 456            |   4 |  100 |   1 | GBP        |
|       62 |        1 |                 70 |          90 | DEF     | 789            |   4 |   50 |   1 | GBP        |
|       62 |        1 |                 70 |         100 | DEF     | 024            |   4 |   20 |   1 | GBP        |
+----------+----------+--------------------+-------------+---------+----------------+-----+------+-----+------------+

Now, you could stop right there if you want. All you'd need to do is use the corrected_hierarchy logic in your calc_cost function, replacing

    and part_no in (
      select component_part
      ...

with

    and parent_sequence_no = sequence_no_

But, as @Def pointed out, you really don't need a PL/SQL function for what you are trying to do.

What you seem to be trying to do is print a hierarchical bill of materials, with the level cost of each item (level cost being the cost of the item's direct and indirect subcomponents).

Here is a query that does that, putting everything together:

with prod_conf_cost_struct_clv ( model_no, revision, sequence_no, part_no, component_part, lvl, cost, qty, purch_curr ) as
( 
SELECT 62, 1, 00, 'XXX', 'ABC', 1, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 10, 'ABC', '123', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 20, '123', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 30, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 40, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 50, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 60, 'ABC', '356', 2, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 70, '356', 'DEF', 3, null, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 80, 'DEF', '456', 4, 100, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 90, 'DEF', '789', 4, 50, 1, 'GBP' FROM DUAL UNION ALL
SELECT 62, 1, 100, 'DEF', '024', 4, 20, 1, 'GBP' FROM DUAL )
-- Step 1: correct for your data model problem, which is the fact that child rows
-- (e.g., operations 30-50) are not *explicitly* linked to their parent rows (e.g.,
-- operation 20)
, corrected_hierarchy ( model_no, revision, parent_sequence_no, sequence_no, part_no, component_part, lvl, cost, qty, purch_curr ) AS
(
SELECT *
FROM   prod_conf_cost_struct_clv c
MATCH_RECOGNIZE (
  PARTITION BY model_no, revision
  ORDER BY sequence_no desc
  MEASURES (P.sequence_no) AS parent_sequence_no,
           c.sequence_no AS sequence_no, c.part_no as part_no, c.component_part as component_part, c.lvl as lvl, c.cost as cost, c.qty as qty, c.purch_curr as purch_curr
  ONE ROW PER MATCH
  AFTER MATCH SKIP TO NEXT ROW
  PATTERN (C S* P?)
  DEFINE
    C AS 1=1,
    S AS S.lvl >= C.lvl,
    P AS P.lvl = C.lvl - 1 AND P.component_part = C.part_no
)
ORDER BY model_no, revision, sequence_no ),
sequence_hierarchy_costs as (
SELECT model_no,
       revision,
       min(sequence_no) sequence_no,
       purch_curr,
       sum(h.qty * h.cost) hierarchy_cost
FROM corrected_hierarchy h
WHERE 1=1
connect by model_no = prior model_no
and        revision = prior revision
and        parent_sequence_no = prior sequence_no
group by model_no, revision, connect_by_root sequence_no, purch_curr )
SELECT level,
       sys_connect_by_path(h.sequence_no, '->') path,
       shc.hierarchy_cost
FROM corrected_hierarchy h 
INNER JOIN sequence_hierarchy_costs shc ON shc.model_no = h.model_no and shc.revision = h.revision and shc.sequence_no = h.sequence_no and shc.purch_curr = h.purch_curr
WHERE h.model_no = 62
and   h.revision = 1
START WITH h.sequence_no = 20
connect by h.model_no = prior h.model_no
and        h.revision = prior h.revision
and        h.parent_sequence_no = prior h.sequence_no;
+-------+----------+----------------+
| LEVEL |   PATH   | HIERARCHY_COST |
+-------+----------+----------------+
|     1 | ->20     |            170 |
|     2 | ->20->30 |            100 |
|     2 | ->20->40 |             50 |
|     2 | ->20->50 |             20 |
+-------+----------+----------------+

You can see this would be a lot easier if parent_sequence_no were in your data model to begin with.

Sign up to request clarification or add additional context in comments.

6 Comments

Thanks for the detailed answer @Matthew; would MATCH_RECOGNIZE be more efficient than the solution @Serg has suggested? I can add a reference column to the view to calculate Parent Sequence No.
MATCH_RECOGNIZE should be more efficient on a large result set. If you are only computing a few costs at a time, a subquery is good too. You'd have to test each way on your data to know for sure. Measure logical I/Os after running each -- do not rely on "cost". But, to repeat, the best thing to do is compute the parent sequence number of each row as you insert it. Do the calculation once, at insertion, rather than over-and-over each time you query.
I have expanded the question with the currency/qty elements, I am having issues with your final query when dealing with currency. I accept I said to ignore it originally...
Before I answer -- are you sure those are your requirements? One would expect the requirement to be to convert the cost from the currency in the table to the currency requested to the function. Otherwise, if I asked for costs in USD, it would look like every part costs 0 USD. Please confirm. Thanks.
Thanks for checking - yes I am looking the the cost of components per currency that make up the assemblies above. Ultimately this query will feed a summary page showing purch_cost column per currency. Exactly as you have pointed out, the USD column based on the example above would show 0.
|
1

Assuming sequence_no column strictly follows tree deep first traversal, missing child/parent relation can be reconstructed in two ways. First we can find a parent sequence_no for every child or find an open interval of sequence_no for children of the parent. Using data provided in OP (no currency column)

with prod_conf_cost_struct_clv (model_no, revision, sequence_no, part_no, component_part, lvl, cost) as
( 
SELECT 62, 1, 00, 'XXX', 'ABC', 1, null FROM DUAL UNION ALL
SELECT 62, 1, 10, 'ABC', '123', 2, null FROM DUAL UNION ALL
SELECT 62, 1, 20, '123', 'DEF', 3, null FROM DUAL UNION ALL
SELECT 62, 1, 30, 'DEF', '456', 4, 100  FROM DUAL UNION ALL
SELECT 62, 1, 40, 'DEF', '789', 4, 50 FROM DUAL UNION ALL
SELECT 62, 1, 50, 'DEF', '024', 4, 20 FROM DUAL UNION ALL
SELECT 62, 1, 60, 'ABC', '356', 2, null FROM DUAL UNION ALL
SELECT 62, 1, 70, '356', 'DEF', 3, null FROM DUAL UNION ALL
SELECT 62, 1, 80, 'DEF', '456', 4, 100 FROM DUAL UNION ALL
SELECT 62, 1, 90, 'DEF', '789', 4, 50 FROM DUAL UNION ALL
SELECT 62, 1, 100, 'DEF', '024', 4, 20 FROM DUAL )
, hier as(
SELECT  model_no, revision, sequence_no, part_no, component_part, lvl, cost
 , (SELECT nvl(min(b.sequence_no), 2147483647/*max integer*/) 
    FROM prod_conf_cost_struct_clv b 
    WHERE a.lvl <> b.lvl-1
    AND a.sequence_no < b.sequence_no) child_bound_s_n
 , (SELECT max(b.sequence_no) 
    FROM prod_conf_cost_struct_clv b 
    WHERE a.lvl = b.lvl+1
    AND a.sequence_no > b.sequence_no) parent_s_n
FROM prod_conf_cost_struct_clv a
)
SELECT model_no, revision, sequence_no,parent_s_n,child_bound_s_n, part_no, component_part, lvl, cost
FROM hier;

Children of the row, say SEQUENCE_NO = 20 are in the (SEQUENCE_NO, CHILD_BOUND_S_N) open interval (20, 60).

MODEL_NO REVISION SEQUENCE_NO   PARENT_S_N  CHILD_BOUND_S_N PART_NO COMPONENT_PART  LVL COST
62       1            0                             20      XXX     ABC             1   
62       1           10          0                  30      ABC     123             2   
62       1           20         10                  60      123     DEF             3   
62       1           30         20                  40      DEF     456             4   100
62       1           40         20                  50      DEF     789             4    50
62       1           50         20                  60      DEF     024             4    20
62       1           60          0                  80      ABC     356             2   
62       1           70         60          2147483647      356     DEF             3   
62       1           80         70                  90      DEF     456             4   100
62       1           90         70                 100      DEF     789             4    50
62       1          100         70          2147483647      DEF     024             4    20

To minimize changes to the original calc_cost function the second way looks better suited here. So, again without currency data

CREATE FUNCTION calc_cost(
    model_no_ number, 
    revision_ number, 
    sequence_no_ in number
    --, currency_ in varchar2
  ) return number 
  is
    qty_ number := 0;
    cost_ number := 0;
    lvl_ number := 0;
  begin

    select 1 /*nvl(new_qty, qty)*/, cost, lvl
      into qty_, cost_, lvl_
    from prod_conf_cost_struct_clv
    where model_no = model_no_
      and revision = revision_
      and sequence_no = sequence_no_
      --and (purch_curr = currency_ or purch_curr is null)
      ;

    if cost_ is null then 
      select sum(calc_cost(model_no, revision, sequence_no/*, purch_curr*/)) into cost_ 
      from prod_conf_cost_struct_clv 
      where model_no = model_no_
        and revision = revision_
        --and (purch_curr = currency_ or purch_curr is null)
        and sequence_no > sequence_no_  
        and sequence_no < (SELECT nvl(min(b.sequence_no), 2147483647) 
                      FROM prod_conf_cost_struct_clv b 
                      WHERE lvl_ <> b.lvl-1
                      AND sequence_no_ < b.sequence_no);
    end if;
    return qty_ * cost_;
  exception when no_data_found then 
    return 0;
  end calc_cost;

And applying to the data above

SELECT calc_cost(62,1,20) FROM DUAL;

CALC_COST(62,1,20)
170

Using in the hierarchy query

with hier as(
 SELECT  model_no, revision, sequence_no, part_no, component_part, lvl, cost
   ,(SELECT nvl(min(b.sequence_no), 2147483647) 
     FROM prod_conf_cost_struct_clv b 
     WHERE a.lvl <> b.lvl-1
     AND a.sequence_no < b.sequence_no) child_bound_s_n
 FROM prod_conf_cost_struct_clv a
)
select level, sys_connect_by_path(sequence_no, '->') path, 
     calc_cost(model_no, revision, sequence_no) total_gbp
from hier
where model_no = 62
  and revision = 1
connect by sequence_no > prior sequence_no 
   and sequence_no < prior child_bound_s_n
  and prior model_no = 62
  and prior revision = 1
start with sequence_no = 20
order by sequence_no;

LEVEL   PATH    TOTAL_GBP
1   ->20        170
2   ->20->30    100
2   ->20->40    50
2   ->20->50    20

1 Comment

In your first example code block you have a sub query for parent_s_n but then you do not use it again. It would be useful to see the alternative solution you had in mind using parent_s_n...
0

Do you actually need the function? It seems like what you are actually looking for is a calculation of a part and each of its components (and recursively their components). Try this:

SELECT sub.root_part, sum(price) AS TOTAL_PRICE
FROM (SELECT CONNECT_BY_ROOT t.part_no AS ROOT_PART, price
      FROM (SELECT DISTINCT model_no, revision, part_no, component_part, price
            FROM prod_conf_cost_struct_clv
            WHERE model_no = 62
            AND revision = 1 )t
      CONNECT BY PRIOR component_part = part_no
      --START WITH part_no = '123'
      ) sub
GROUP BY sub.root_part;

I commented out the START WITH, but you can put it back in if you are really looking for just that one ID.

1 Comment

I think that your query does not replicate what my function is doing. The function takes the cost and multiplies this by the qty. if there is no cost at that level then it finds the cost of the levels below multiplies those by the quantity at the level; then multiplies the total by the quantity at the current level.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.