April 11, 2012
I recently received an interesting request for assistance from an ERP email mailing list. The author of the email wanted to reproduce a canned report found in the ERP package so that the information could be published on a Microsoft Sharepoint system. The author of the email is using SQL Server for the database backend, but we will ignore that bit of complexity for the moment.
The canned report that ships with the ERP system looks similar to the following:
Basically, the end user enters a start and an end date for the report, the ERP system checks the shop resource availability calendar for the days between the start and end dates, and then calculates the Capacity value from that calendar (there is a default shop resource calendar, and an override calendar for some of the shop resources). The Act Hrs column in the report is simply the sum of hours calculated from the employee labor transactions that are recorded in real-time on the production floor – that column value is easy to calculate, but is subject to minor rounding errors and date calculation errors when an employee labor transaction starts before midnight and ends after midnight on either the start date or end date specified by the end user running the report. The Est Hrs column in the report is a little more difficult to calculate, requiring a bit of calculation that determines what item the employee actually claimed to have produced, and in what quantity – what if the employee claimed to have produced 6 of 10 widgets in the specified time period, might he have completed 99% of the seventh widget but not yet reported that widget complete? The EFF column is simply the Est Hours column divided by the Act Hrs column, with the result multiplied by 100. The Act Util column is also easy to calculate: the Act Hrs column value divided by the Capacity column value, with the result multiplied by 100. The Est Util column is simply the Est Hrs column value divided by the Capacity column value, with the result multiplied by 100.
So, where do we start in trying to reproduce this particular report? How about enabling a 10046 trace for the session that creates the report. Analyzing the resulting trace file might provide some insight into how the report is built. Below is a summary of the SQL statements found in the trace file:
Query 1: (this query that retrieves the estimated hours, shop resource description, and various other information that does not appear on the report – this appears to be the query that provokes the ERP system to issue the queries that follow)
SELECT L.RESOURCE_ID, L.WORKORDER_TYPE, L.WORKORDER_BASE_ID, L.WORKORDER_LOT_ID, L.WORKORDER_SPLIT_ID, L.WORKORDER_SUB_ID, L.OPERATION_SEQ_NO, L.TRANSACTION_DATE, L.HOURS_WORKED, L.GOOD_QTY, L.TYPE, L.EMPLOYEE_ID, OP.SETUP_HRS, OP.RUN, OP.RUN_TYPE, OP.LOAD_SIZE_QTY, OP.CALC_END_QTY, R.DESCRIPTION, RTRIM(E.LAST_NAME), RTRIM(E.FIRST_NAME), E.MIDDLE_INITIAL, W.PART_ID, P.DESCRIPTION, L.TRANSACTION_ID FROM LABOR_TICKET L, OPERATION OP, SHOP_RESOURCE R, EMPLOYEE E, WORK_ORDER W, PART P WHERE L.RESOURCE_ID = R.ID AND L.EMPLOYEE_ID = E.ID AND L.WORKORDER_TYPE = OP.WORKORDER_TYPE AND L.WORKORDER_BASE_ID = OP.WORKORDER_BASE_ID AND L.WORKORDER_LOT_ID = OP.WORKORDER_LOT_ID AND L.WORKORDER_SPLIT_ID = OP.WORKORDER_SPLIT_ID AND L.WORKORDER_SUB_ID = OP.WORKORDER_SUB_ID AND L.OPERATION_SEQ_NO = OP.SEQUENCE_NO AND W.TYPE = OP.WORKORDER_TYPE AND W.BASE_ID = OP.WORKORDER_BASE_ID AND W.LOT_ID = OP.WORKORDER_LOT_ID AND W.SPLIT_ID = OP.WORKORDER_SPLIT_ID AND W.SUB_ID = OP.WORKORDER_SUB_ID AND W.PART_ID = P.ID (+) AND TRUNC(L.TRANSACTION_DATE) BETWEEN :1 AND :2 ORDER BY L.RESOURCE_ID, L.WORKORDER_TYPE, L.WORKORDER_BASE_ID, L.WORKORDER_LOT_ID, L.WORKORDER_SPLIT_ID, L.WORKORDER_SUB_ID, L.OPERATION_SEQ_NO;
Query 2: (this query returns the quantity of a specific resource that is available for each of three shifts… for instance, the number of employees with a specific skill)
SELECT SHIFT_1_CAPACITY, SHIFT_2_CAPACITY, SHIFT_3_CAPACITY FROM SHOP_RESOURCE WHERE ID = :1;
Query 3: (this query returns the number of hours of availability for a specific resource, for each day of the week; the default resource calendar has a NULL value for the RESOURCE_ID column, so the specified sort order will return the default resource calendar rows before the over-ride calendar for a specific shop resource)
SELECT DAY_OF_WEEK, START_OF_DAY, SHIFT_1, SHIFT_2, SHIFT_3 FROM CALENDAR_WEEK WHERE (SCHEDULE_ID IS NULL OR SCHEDULE_ID = 'STANDARD') AND (RESOURCE_ID IS NULL OR RESOURCE_ID = :1 ) ORDER BY SCHEDULE_ID, RESOURCE_ID DESC, DAY_OF_WEEK;
Query 4: (this query returns the exceptions, planned days of unavailabilty, for the default resource calendar and the shop resource specific over-ride calendar. I just noticed a logic error in this SQL statement – what about those scheduled exceptions that start before the user specified start date and/or those scheduled exceptions that end after the user specified end date, where some of those dates fall into the user specified date range?)
SELECT START_DATE, END_DATE, START_OF_DAY, SHIFT_1, SHIFT_2, SHIFT_3, SHIFT_1_CAPACITY, SHIFT_2_CAPACITY, SHIFT_3_CAPACITY FROM CALENDAR_CHANGE WHERE (SCHEDULE_ID IS NULL OR SCHEDULE_ID = 'STANDARD') AND (RESOURCE_ID IS NULL OR RESOURCE_ID = :1 ) AND START_DATE >= :2 AND END_DATE <= :3 ORDER BY SCHEDULE_ID, RESOURCE_ID, START_DATE;
Query 5: (this query returns the number of operation setup hours for a specific operation, time spent preparing to produce parts, reported on the shop floor – it would seem that Query 1 could be modified to return this information)
SELECT SUM(HOURS_WORKED) FROM LABOR_TICKET WHERE WORKORDER_TYPE = :1 AND WORKORDER_BASE_ID = :2 AND WORKORDER_LOT_ID = :3 AND WORKORDER_SPLIT_ID = :4 AND WORKORDER_SUB_ID = :5 AND OPERATION_SEQ_NO = :6 AND TYPE = 'S';
—
Now that we have a general idea of what steps the ERP system is performing to reproduce the report, where do we start? We start by telling the OP (the person with the SQL Server backend) that with an Oracle Database backend, we could produce this report using a single SQL statement (without any stored procedures) – that approach would certainly eliminate a lot of back and forth communication between the client computer and the database server, which is an important consideration if a high latency network connects the two devices. For the sake of simplicity, I will set the above query 4 aside for now. Since there is an index on the LABOR_TICKET.TRANSACTION_DATE column, the ERP system loses the benefit of that index by applying the TRUNC function to that column’s values – so we will need to fix that problem.
First, we need to know how many hours of capacity are available for each resource. If a specific over-ride schedule (shop calendar) is not available for a shop resource, then the standard schedule is used. To retrieve the schedules for the shop resources that do not have a defined unique schedule, we can create a Cartesian join between two tables with a NOT EXISTS clause to exclude those shop resources with an over-ride schedule:
SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'DEFAULT' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID IS NULL AND NOT EXISTS ( SELECT C.RESOURCE_ID FROM CALENDAR_WEEK C WHERE SR.ID=C.RESOURCE_ID);
We also need to retrieve the shop resources with defined over-ride schedules:
SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'RESOURCE' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID=SR.ID;
Finally, we need to UNION ALL the above two queries:
SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'DEFAULT' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID IS NULL AND NOT EXISTS ( SELECT C.RESOURCE_ID FROM CALENDAR_WEEK C WHERE SR.ID=C.RESOURCE_ID) UNION ALL SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'RESOURCE' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID=SR.ID ORDER BY RESOURCE_ID, DAY_OF_WEEK;
The ERP system uses the following code numbers to represent each day of the week in the output produced by the above SQL statement:
0: Saturday 1: Sunday 2: Monday 3: Tuesday 4: Wednesday 5: Thursday 6: Friday
On Oracle Database, we are able to use the TO_DATE function to almost reproduce the above translation table, although NLS settings that are in effect could produce results that require further adjustment. The following formula returns 1 for a Sunday and 6 for a Friday, but 7 for Saturday:
TO_DATE(TRANSACTION_DATE, 'D')
We are able to convert the 7 value for Saturdays into a 0 by using the MOD function to return the remainder value after dividing by 7:
MOD(TO_DATE(TRANSACTION_DATE, 'D'), 7)
Assume that we want to generate a report for the dates between March 26, 2012 and April 14, 2012. We need a way to determine the number of Mondays, Tuesdays, Wednesdays, Thursdays, Fridays, Saturdays, and Sundays between the two dates (including the end-point dates). There are a couple of approaches to this problem:
- Use the CONNECT BY LEVEL syntax to generate a row for each date between the start and end dates.
- Use a pre-created, statistically defined table that simply lists all of the dates between an arbitrary start and end date.
- Use a stored procedure to calculate the number of each day of the week between the start and end dates.
- Mathematically calculate within the SQL statement the number of each day of the week.
I will use the first of the above approaches… I suppose this approach would be a bit of a challenge with a SQL Server backend.
SELECT TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1) MY_DATE, TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D') AS D, MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) DAY_OF_WEEK FROM DUAL CONNECT BY LEVEL <= (TO_DATE('04/14/2012','MM/DD/YYYY') - TO_DATE('03/26/2012','MM/DD/YYYY'))+1; MY_DATE D DAY_OF_WEEK --------- - ----------- 26-MAR-12 2 2 27-MAR-12 3 3 28-MAR-12 4 4 29-MAR-12 5 5 30-MAR-12 6 6 31-MAR-12 7 0 01-APR-12 1 1 02-APR-12 2 2 03-APR-12 3 3 04-APR-12 4 4 05-APR-12 5 5 06-APR-12 6 6 07-APR-12 7 0 08-APR-12 1 1 09-APR-12 2 2 10-APR-12 3 3 11-APR-12 4 4 12-APR-12 5 5 13-APR-12 6 6 14-APR-12 7 0
If we were to use the above in a production environment, we certainly would convert the literal (constant) date values to bind variables. The DAY_OF_WEEK column values (assuming that the NLS settings result in Sunday being the first day of the week) are the important values in the above output. If we also implemented query 4 used by the ERP system, then the MY_DATE column values would also need to be considered. How many Mondays, Tuesdays, Wednesdays, etc. are between the two dates?:
SELECT MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) DAY_OF_WEEK, COUNT(*) NUM_DAYS FROM DUAL CONNECT BY LEVEL <= (TO_DATE('04/14/2012','MM/DD/YYYY') - TO_DATE('03/26/2012','MM/DD/YYYY'))+1 GROUP BY MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7); DAY_OF_WEEK NUM_DAYS ----------- ---------- 0 3 1 2 2 3 3 3 4 3 5 3 6 3
As shown above, just 2 Sundays, but 3 of every other day of the week.
We now have two SQL statements (the one that indicates the available capacity per day of week, and the other that indicates the number of each day of the week in the user specified time interval) that need to be joined together:
SELECT C.RESOURCE_ID, C.DAY_OF_WEEK, (C.HOURS_1 + C.HOURS_2 + C.HOURS_3) * D.NUM_DAYS FROM (SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'DEFAULT' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID IS NULL AND NOT EXISTS ( SELECT C.RESOURCE_ID FROM CALENDAR_WEEK C WHERE SR.ID=C.RESOURCE_ID) UNION ALL SELECT SR.ID AS RESOURCE_ID, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'RESOURCE' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID=SR.ID) C, (SELECT MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) DAY_OF_WEEK, COUNT(*) NUM_DAYS FROM DUAL CONNECT BY LEVEL <= (TO_DATE('04/14/2012','MM/DD/YYYY') - TO_DATE('03/26/2012','MM/DD/YYYY'))+1 GROUP BY MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) ) D WHERE C.DAY_OF_WEEK=D.DAY_OF_WEEK;
Above is a somewhat large and complicated SQL statement, if you simply jumped into the middle of this article.
We still need to GROUP the result by the RESOURCE_ID column and add in the DESCRIPTION that is associated with each RESOURCE_ID:
SELECT C.RESOURCE_ID, C.DESCRIPTION, SUM((C.HOURS_1 + C.HOURS_2 + C.HOURS_3) * D.NUM_DAYS) CAPACITY FROM (SELECT SR.ID AS RESOURCE_ID, SR.DESCRIPTION, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'DEFAULT' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID IS NULL AND NOT EXISTS ( SELECT C.RESOURCE_ID FROM CALENDAR_WEEK C WHERE SR.ID=C.RESOURCE_ID) UNION ALL SELECT SR.ID AS RESOURCE_ID, SR.DESCRIPTION, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'RESOURCE' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID=SR.ID) C, (SELECT MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) DAY_OF_WEEK, COUNT(*) NUM_DAYS FROM DUAL CONNECT BY LEVEL <= (TO_DATE('04/14/2012','MM/DD/YYYY') - TO_DATE('03/26/2012','MM/DD/YYYY'))+1 GROUP BY MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) ) D WHERE C.DAY_OF_WEEK=D.DAY_OF_WEEK GROUP BY C.RESOURCE_ID, C.DESCRIPTION ORDER BY RESOURCE_ID;
The above SQL statement results in the following output – we now have the first column in the ERP system’s canned report:
At this point, my claim of being able to reproduce this canned report in a single SQL statement might seem suspect – all of the above work for just a single column in the report. It’s easy, just build the report in separate units, and then join the separate units.
As I mentioned earlier, calculating the estimated average hours and average actual hours could be a little difficult. We might start with something like this:
SELECT LT.RESOURCE_ID, O.RUN_HRS AS EST_HOURS, O.CALC_END_QTY AS OPERATION_QTY, ROUND(O.RUN_HRS/O.CALC_END_QTY,2) AS AVG_EST_HOURS, SUM(LT.HOURS_WORKED) AS ACT_HOURS, SUM(LT.GOOD_QTY) AS COMPLETED_QTY, ROUND(DECODE(SUM(LT.GOOD_QTY),0,SUM(LT.HOURS_WORKED),SUM(LT.HOURS_WORKED)/SUM(LT.GOOD_QTY)),2) AVG_ACT_HOURS FROM LABOR_TICKET LT, OPERATION O WHERE LT.WORKORDER_TYPE='W' AND LT.TYPE='R' AND LT.WORKORDER_TYPE=O.WORKORDER_TYPE AND LT.WORKORDER_BASE_ID=O.WORKORDER_BASE_ID AND LT.WORKORDER_LOT_ID=O.WORKORDER_LOT_ID AND LT.WORKORDER_SPLIT_ID=O.WORKORDER_SPLIT_ID AND LT.WORKORDER_SUB_ID=O.WORKORDER_SUB_ID AND LT.OPERATION_SEQ_NO=O.SEQUENCE_NO AND LT.SHIFT_DATE BETWEEN TO_DATE('03/26/2012','MM/DD/YYYY') AND TO_DATE('04/14/2012','MM/DD/YYYY') GROUP BY LT.RESOURCE_ID, O.RUN_HRS, O.CALC_END_QTY ORDER BY LT.RESOURCE_ID;
The output of the above SQL statement looks similar to the following:
Nice, if we were just interested in summarizing by the individual operations. However, the above will not work as a starting point for the next two columns in the report because we need to summarized by the RESOURCE_ID column – if we group on that column, we will throw off the accuracy of the efficiency calculations in the report. So, we need to take a step back and try again.
First, we will summarize the data from the LABOR_TICKET table by itself – notice that I am not using the TRUNC function around the date column, and I am using the SHIFT_DATE column rather than the TRANSACTION_DATE column (the SHIFT_DATE column in this table is better able to work around the issue with labor transactions that cross midnight, and the time portion of the date values are already truncated to midnight):
SELECT LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID, SUM(LT.HOURS_WORKED) AS ACT_HOURS, SUM(LT.GOOD_QTY) AS COMPLETED_QTY FROM LABOR_TICKET LT WHERE LT.SHIFT_DATE BETWEEN TO_DATE('03/26/2012','MM/DD/YYYY') AND TO_DATE('04/14/2012','MM/DD/YYYY') AND LT.TYPE='R' AND LT.WORKORDER_TYPE='W' GROUP BY LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID HAVING SUM(LT.HOURS_WORKED)>0;
Now that we have pre-summarized the results from the LABOR_TICKET table, we can slide the above SQL statement into an inline view and join that inline view with the OPERATION table:
SELECT LT.RESOURCE_ID, SUM(ROUND(O.RUN_HRS/O.CALC_END_QTY*LT.COMPLETED_QTY,2)) AS ENG_HOURS, SUM(LT.ACT_HOURS) AS ACT_HOURS, ROUND(SUM(ROUND(O.RUN_HRS/O.CALC_END_QTY*LT.COMPLETED_QTY,2))/SUM(LT.ACT_HOURS)*100,2) AS EFF FROM (SELECT LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID, SUM(LT.HOURS_WORKED) AS ACT_HOURS, SUM(LT.GOOD_QTY) AS COMPLETED_QTY FROM LABOR_TICKET LT WHERE LT.SHIFT_DATE BETWEEN TO_DATE('03/26/2012','MM/DD/YYYY') AND TO_DATE('04/14/2012','MM/DD/YYYY') AND LT.TYPE='R' AND LT.WORKORDER_TYPE='W' GROUP BY LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID HAVING SUM(LT.HOURS_WORKED)>0) LT, OPERATION O WHERE LT.WORKORDER_TYPE=O.WORKORDER_TYPE AND LT.WORKORDER_BASE_ID=O.WORKORDER_BASE_ID AND LT.WORKORDER_LOT_ID=O.WORKORDER_LOT_ID AND LT.WORKORDER_SPLIT_ID=O.WORKORDER_SPLIT_ID AND LT.WORKORDER_SUB_ID=O.WORKORDER_SUB_ID AND LT.OPERATION_SEQ_NO=O.SEQUENCE_NO GROUP BY LT.RESOURCE_ID ORDER BY LT.RESOURCE_ID;
The result of the above SQL statement is much more useful than the original attempt. Note that the above SQL statement makes certain assumptions about the estimated (ENG_HOURS) that the ERP system does not make, so the resulting numbers could be slightly different.
We now have three more columns of our report.
The final step is to put the complete SQL statement together, joining the previously created portion of the SQL statement that determined the available capacity with the just created SQL statement that determined the actual and estimated utilization. The resulting SQL statement appears as follows:
SELECT C.RESOURCE_ID, C.DESCRIPTION, C.CAPACITY, E.ACT_HOURS, E.ENG_HOURS AS EST_HOURS, E.EFF, DECODE(C.CAPACITY,0,0,ROUND(E.ACT_HOURS/C.CAPACITY*100,2)) AS ACT_UTIL, DECODE(C.CAPACITY,0,0,ROUND(E.ENG_HOURS/C.CAPACITY*100,2)) AS EST_UTIL FROM (SELECT C.RESOURCE_ID, C.DESCRIPTION, SUM((C.HOURS_1 + C.HOURS_2 + C.HOURS_3) * D.NUM_DAYS) CAPACITY FROM (SELECT SR.ID AS RESOURCE_ID, SR.DESCRIPTION, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'DEFAULT' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID IS NULL AND NOT EXISTS ( SELECT C.RESOURCE_ID FROM CALENDAR_WEEK C WHERE SR.ID=C.RESOURCE_ID) UNION ALL SELECT SR.ID AS RESOURCE_ID, SR.DESCRIPTION, CW.DAY_OF_WEEK, CW.SHIFT_1*SR.SHIFT_1_CAPACITY AS HOURS_1, CW.SHIFT_2*SR.SHIFT_2_CAPACITY AS HOURS_2, CW.SHIFT_3*SR.SHIFT_3_CAPACITY AS HOURS_3, 'RESOURCE' CALENDAR_TYPE FROM CALENDAR_WEEK CW, SHOP_RESOURCE SR WHERE (CW.SCHEDULE_ID IS NULL OR CW.SCHEDULE_ID = 'STANDARD') AND CW.RESOURCE_ID=SR.ID) C, (SELECT MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) DAY_OF_WEEK, COUNT(*) NUM_DAYS FROM DUAL CONNECT BY LEVEL <= (TO_DATE('04/14/2012','MM/DD/YYYY') - TO_DATE('03/26/2012','MM/DD/YYYY'))+1 GROUP BY MOD(TO_CHAR(TO_DATE('03/26/2012','MM/DD/YYYY')+(LEVEL-1), 'D'),7) ) D WHERE C.DAY_OF_WEEK=D.DAY_OF_WEEK GROUP BY C.RESOURCE_ID, C.DESCRIPTION) C, (SELECT LT.RESOURCE_ID, SUM(ROUND(O.RUN_HRS/O.CALC_END_QTY*LT.COMPLETED_QTY,2)) AS ENG_HOURS, SUM(LT.ACT_HOURS) AS ACT_HOURS, ROUND(SUM(ROUND(O.RUN_HRS/O.CALC_END_QTY*LT.COMPLETED_QTY,2))/SUM(LT.ACT_HOURS)*100,2) AS EFF FROM (SELECT LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID, SUM(LT.HOURS_WORKED) AS ACT_HOURS, SUM(LT.GOOD_QTY) AS COMPLETED_QTY FROM LABOR_TICKET LT WHERE LT.SHIFT_DATE BETWEEN TO_DATE('03/26/2012','MM/DD/YYYY') AND TO_DATE('04/14/2012','MM/DD/YYYY') AND LT.TYPE='R' AND LT.WORKORDER_TYPE='W' GROUP BY LT.WORKORDER_TYPE, LT.WORKORDER_BASE_ID, LT.WORKORDER_LOT_ID, LT.WORKORDER_SPLIT_ID, LT.WORKORDER_SUB_ID, LT.OPERATION_SEQ_NO, LT.RESOURCE_ID HAVING SUM(LT.HOURS_WORKED)>0) LT, OPERATION O WHERE LT.WORKORDER_TYPE=O.WORKORDER_TYPE AND LT.WORKORDER_BASE_ID=O.WORKORDER_BASE_ID AND LT.WORKORDER_LOT_ID=O.WORKORDER_LOT_ID AND LT.WORKORDER_SPLIT_ID=O.WORKORDER_SPLIT_ID AND LT.WORKORDER_SUB_ID=O.WORKORDER_SUB_ID AND LT.OPERATION_SEQ_NO=O.SEQUENCE_NO GROUP BY LT.RESOURCE_ID) E WHERE E.RESOURCE_ID=C.RESOURCE_ID ORDER BY C.RESOURCE_ID;
The resulting output appears as follows:
Well, that was easy… 🙂
The solution reminds me of the phrase “How to Eat an Elephant“. Elephant poaching is illegal in most parts of the world, so I propose changing this phrase to “How to Walk an Elephant”. One step at a time, and make certain not to stand directly in front or directly behind. 😉
TO_CHAR(..,’D’) depends on your client session nls settings. Try following with different client session nls_territory.
select dt
, to_char(dt,’day’) day
, MOD(TO_CHAR(dt, ‘D’),7)
, MOD(dt-trunc(dt,’IW’)+1,7) dn
from (
select TO_DATE(’03/26/2012′,’MM/DD/YYYY’)+(LEVEL-1) dt
from dual
connect by level<8
);
alter session set nls_territory=finland;
alter session set nls_territory=america;
alter session set nls_territory=bangladesh;
NLS_DATE_TERRITORY would be nice to have as a parameter option in to_char(datetime).
Timo,
Thank you for the comment – you raise a very valid point. In the article I made the following comment:
When I wrote that statement, I expected that there was a simple format override for the TO_CHAR function that would permit altering the NLS settings for only the TO_CHAR function, similar to what is shown in the documentation:
http://docs.oracle.com/cd/B28359_01/olap.111/b28126/dml_functions_2113.htm
After seeing your comment, I went back to the following example in this blog article, trying the SQL statement with the NLS_TERRITORY values that you specified:
FINLAND:
AMERICA:
BANGLADESH:
After spending a bit of time experimenting with different NLS format parameters inside the TO_CHAR function, it became apparent that the MOD function simply will not work for this solution for some NLS regions. As a result, I am proposing a work-around:
FINLAND:
AMERICA:
BANGLADESH:
The OLD_DAY_OF_WEEK and DAY columns, of course, could be removed from the above SQL statements.
[…] Reproducing a Canned Report using a Single SQL Statement is another good post by Charles Hooper. […]