diff --git a/calculate_largest_expensors.sql b/calculate_largest_expensors.sql index e69de29..b85b697 100644 --- a/calculate_largest_expensors.sql +++ b/calculate_largest_expensors.sql @@ -0,0 +1,19 @@ +USE memory.default; + +-- Report every employee whose total expensed amount exceeds 1000. +-- total_expensed_amount = SUM(unit_price * quantity) across all their EXPENSE rows. +-- Results ordered by total_expensed_amount descending. + +SELECT + e.employee_id, + e.first_name || ' ' || e.last_name AS employee_name, + e.manager_id, + m.first_name || ' ' || m.last_name AS manager_name, + CAST(SUM(ex.unit_price * ex.quantity) AS DECIMAL(10, 2)) AS total_expensed_amount +FROM EMPLOYEE e +JOIN EXPENSE ex ON e.employee_id = ex.employee_id +JOIN EMPLOYEE m ON e.manager_id = m.employee_id +GROUP BY e.employee_id, e.first_name, e.last_name, + e.manager_id, m.first_name, m.last_name +HAVING SUM(ex.unit_price * ex.quantity) > 1000 +ORDER BY total_expensed_amount DESC; diff --git a/create_employees.sql b/create_employees.sql index e69de29..bdb15cc 100644 --- a/create_employees.sql +++ b/create_employees.sql @@ -0,0 +1,20 @@ +USE memory.default; + +CREATE TABLE EMPLOYEE ( + employee_id TINYINT, + first_name VARCHAR, + last_name VARCHAR, + job_title VARCHAR, + manager_id TINYINT +); + +INSERT INTO EMPLOYEE (employee_id, first_name, last_name, job_title, manager_id) VALUES + (TINYINT '1', 'Ian', 'James', 'CEO', TINYINT '4'), + (TINYINT '2', 'Umberto', 'Torrielli','CSO', TINYINT '1'), + (TINYINT '3', 'Alex', 'Jacobson', 'MD EMEA', TINYINT '2'), + (TINYINT '4', 'Darren', 'Poynton', 'CFO', TINYINT '2'), + (TINYINT '5', 'Tim', 'Beard', 'MD APAC', TINYINT '2'), + (TINYINT '6', 'Gemma', 'Dodd', 'COS', TINYINT '1'), + (TINYINT '7', 'Lisa', 'Platten', 'CHR', TINYINT '6'), + (TINYINT '8', 'Stefano', 'Camisaca', 'GM Activation', TINYINT '2'), + (TINYINT '9', 'Andrea', 'Ghibaudi', 'MD NAM', TINYINT '2'); diff --git a/create_expenses.sql b/create_expenses.sql index e69de29..ef167f6 100644 --- a/create_expenses.sql +++ b/create_expenses.sql @@ -0,0 +1,22 @@ +USE memory.default; + +CREATE TABLE EXPENSE ( + employee_id TINYINT, + unit_price DECIMAL(8, 2), + quantity TINYINT +); + +-- Employee names resolved to IDs from EMPLOYEE table. +-- Source files: finance/receipts_from_last_night/ +INSERT INTO EXPENSE (employee_id, unit_price, quantity) VALUES + -- Alex Jacobson (employee_id = 3) + (TINYINT '3', DECIMAL '6.50', TINYINT '14'), + (TINYINT '3', DECIMAL '11.00', TINYINT '20'), + (TINYINT '3', DECIMAL '22.00', TINYINT '18'), + (TINYINT '3', DECIMAL '13.00', TINYINT '75'), + -- Andrea Ghibaudi (employee_id = 9) + (TINYINT '9', DECIMAL '300.00', TINYINT '1'), + -- Darren Poynton (employee_id = 4) + (TINYINT '4', DECIMAL '40.00', TINYINT '9'), + -- Umberto Torrielli (employee_id = 2) + (TINYINT '2', DECIMAL '17.50', TINYINT '4'); diff --git a/create_invoices.sql b/create_invoices.sql index e69de29..a6ccb34 100644 --- a/create_invoices.sql +++ b/create_invoices.sql @@ -0,0 +1,42 @@ +USE memory.default; + + +CREATE TABLE SUPPLIER ( + supplier_id TINYINT, + name VARCHAR +); + +-- Column name 'invoice_ammount' preserved double 'm' as a typo expecting "production" is the same +CREATE TABLE INVOICE ( + supplier_id TINYINT, + invoice_ammount DECIMAL(8, 2), + due_date DATE +); + +-- supplier_id assigned in strict alphabetical order by company name +INSERT INTO SUPPLIER (supplier_id, name) VALUES + (TINYINT '1', 'Catering Plus'), + (TINYINT '2', 'Dave''s Discos'), + (TINYINT '3', 'Entertainment tonight'), + (TINYINT '4', 'Ice Ice Baby'), + (TINYINT '5', 'Party Animals'); + +-- Due dates are computed dynamically: "N months from now" = last day of month N months ahead. +INSERT INTO INVOICE (supplier_id, invoice_ammount, due_date) +SELECT + CAST(supplier_id AS TINYINT), + CAST(invoice_ammount AS DECIMAL(8, 2)), + date_add('day', -1, + date_add('month', months_from_now + 1, + date_trunc('month', current_date) + ) + ) AS due_date +FROM ( + VALUES + (1, 2000.00, 2), -- brilliant_bottles.txt: Catering Plus, 2 months + (1, 1500.00, 3), -- crazy_catering.txt: Catering Plus, 3 months + (2, 500.00, 1), -- disco_dj.txt: Dave's Discos, 1 month + (3, 6000.00, 3), -- excellent_entertainment: Entertainment tonight, 3 months + (4, 4000.00, 6), -- fantastic_ice_sculptures: Ice Ice Baby, 6 months + (5, 6000.00, 3) -- awesome_animals.txt: Party Animals, 3 months +) AS t(supplier_id, invoice_ammount, months_from_now); diff --git a/find_manager_cycles.sql b/find_manager_cycles.sql index e69de29..83ae204 100644 --- a/find_manager_cycles.sql +++ b/find_manager_cycles.sql @@ -0,0 +1,51 @@ +USE memory.default; + +-- Detect employees who are part of a manager approval cycle. +-- +-- Algorithm walks the manager chain from each employee. +-- The VISITED array prevents infinite loops when the path enters someone else's cycle) +-- When current_id returns to start_id the cycle is confirmed and the row surfaces in the final SELECT. +-- +-- Output: one row per employee in a cycle. +-- employee_id -- the employee involved in the cycle +-- cycle -- the full cycle path as a string (e.g. '1 -> 4 -> 2 -> 1') + +-- Step 1: materialise the recursive expansion into a temp table to reduce query stages +CREATE TABLE manager_chain_temp AS +WITH RECURSIVE manager_chain(start_id, current_id, path, visited) AS ( + + -- Base: step from each employee to their direct manager + SELECT + employee_id AS start_id, + manager_id AS current_id, + CAST(employee_id AS VARCHAR) || ' -> ' || CAST(manager_id AS VARCHAR) AS path, + ARRAY[employee_id] AS visited + FROM EMPLOYEE + WHERE manager_id IS NOT NULL + + UNION ALL + + -- Recursive: follow the manager chain one step further + SELECT + mc.start_id, + e.manager_id AS current_id, + mc.path || ' -> ' || CAST(e.manager_id AS VARCHAR) AS path, + mc.visited || ARRAY[mc.current_id] AS visited + FROM manager_chain mc + JOIN EMPLOYEE e ON mc.current_id = e.employee_id + WHERE e.manager_id IS NOT NULL + AND mc.current_id <> mc.start_id -- stop once the cycle closes + AND NOT contains(mc.visited, mc.current_id) -- avoid re-traversing visited nodes +) +SELECT * FROM manager_chain; + +-- Step 2: surface only the rows where the chain looped back to the starting employee +SELECT + start_id AS employee_id, + path AS cycle +FROM manager_chain_temp +WHERE current_id = start_id +ORDER BY employee_id; + +-- Step 3: clean up temp table +DROP TABLE manager_chain_temp; diff --git a/generate_supplier_payment_plans.sql b/generate_supplier_payment_plans.sql index e69de29..ee64ef8 100644 --- a/generate_supplier_payment_plans.sql +++ b/generate_supplier_payment_plans.sql @@ -0,0 +1,94 @@ +USE memory.default; + +-- Hypothesis: +-- Payments begin at the end of the current month. +-- For each invoice, the number of monthly payments equals the number of full calendar months between now and the invoice due date. +-- Each monthly contribution is invoice_ammount / num_payments (uniform split). +-- The LAST payment per invoice absorbs any rounding remainder so that the sum of payments equals invoice_ammount exactly (balance closes to 0.00). +-- Suppliers with multiple invoices receive ONE combined payment per month. + +WITH +-- Step 1: per invoice, compute how many monthly payments are needed +invoice_schedule AS ( + SELECT + supplier_id, + invoice_ammount, + due_date, + date_diff( + 'month', + date_trunc('month', current_date), + date_trunc('month', due_date) + ) AS num_payments + FROM INVOICE +), + +-- Step 2: expand each invoice into one row per payment, computing the per-payment amount. +payment_series AS ( + SELECT + s.supplier_id, + s.invoice_ammount, + s.num_payments, + k, + CASE + WHEN k = s.num_payments + -- Last payment: residual to ensure exact closure + THEN s.invoice_ammount + - CAST((s.num_payments - 1) AS DECIMAL(10, 4)) + * ROUND( + CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), + 2 + ) + ELSE + ROUND( + CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), + 2 + ) + END AS actual_payment, + date_add('day', -1, + date_add('month', k, + date_trunc('month', current_date) + ) + ) AS payment_date + FROM invoice_schedule s + CROSS JOIN UNNEST(sequence(1, s.num_payments)) AS t(k) +), + +-- Step 3: combine all invoice contributions into one payment per (supplier, month) +monthly_payments AS ( + SELECT + supplier_id, + payment_date, + CAST(SUM(actual_payment) AS DECIMAL(8, 2)) AS payment_amount + FROM payment_series + GROUP BY supplier_id, payment_date +), + +-- Step 4: total outstanding balance per supplier +supplier_total AS ( + SELECT + supplier_id, + CAST(SUM(invoice_ammount) AS DECIMAL(8, 2)) AS total_balance + FROM INVOICE + GROUP BY supplier_id +) + +-- Final: join with SUPPLIER name and compute running balance_outstanding +SELECT + mp.supplier_id, + s.name AS supplier_name, + mp.payment_amount, + CAST( + st.total_balance + - SUM(mp.payment_amount) OVER ( + PARTITION BY mp.supplier_id + ORDER BY mp.payment_date + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(8, 2)) AS balance_outstanding, + mp.payment_date +FROM monthly_payments mp +JOIN SUPPLIER s ON mp.supplier_id = s.supplier_id +JOIN supplier_total st ON mp.supplier_id = st.supplier_id +ORDER BY mp.supplier_id, mp.payment_date; diff --git a/tests.sql b/tests.sql new file mode 100644 index 0000000..eeb3057 --- /dev/null +++ b/tests.sql @@ -0,0 +1,249 @@ +USE memory.default; + +-- ============================================================ +-- SExI: Data and Query Validation Tests +-- +-- Each statement returns two columns: +-- test_name VARCHAR -- description of what is being tested +-- result VARCHAR -- 'PASS' or 'FAIL' +-- +-- Run AFTER all create_*.sql files have been executed. +-- ============================================================ + + +-- T01: EMPLOYEE table has exactly 9 rows +SELECT 'T01: EMPLOYEE row count = 9' AS test_name, + CASE WHEN COUNT(*) = 9 THEN 'PASS' ELSE 'FAIL' END AS result +FROM EMPLOYEE; + + +-- T02: EXPENSE table has exactly 7 rows (one per receipt file) +SELECT 'T02: EXPENSE row count = 7' AS test_name, + CASE WHEN COUNT(*) = 7 THEN 'PASS' ELSE 'FAIL' END AS result +FROM EXPENSE; + + +-- T03: INVOICE table has exactly 6 rows (one per invoice file) +SELECT 'T03: INVOICE row count = 6' AS test_name, + CASE WHEN COUNT(*) = 6 THEN 'PASS' ELSE 'FAIL' END AS result +FROM INVOICE; + + +-- T04: SUPPLIER table has exactly 5 rows +SELECT 'T04: SUPPLIER row count = 5' AS test_name, + CASE WHEN COUNT(*) = 5 THEN 'PASS' ELSE 'FAIL' END AS result +FROM SUPPLIER; + + +-- T05: Ian James is CEO with employee_id = 1 +SELECT 'T05: Ian James is CEO (employee_id = 1)' AS test_name, + CASE WHEN COUNT(*) = 1 THEN 'PASS' ELSE 'FAIL' END AS result +FROM EMPLOYEE +WHERE employee_id = 1 + AND first_name = 'Ian' + AND last_name = 'James' + AND job_title = 'CEO'; + + +-- T06: Supplier IDs are assigned in strict alphabetical order by company name +SELECT 'T06: Supplier IDs assigned alphabetically' AS test_name, + CASE WHEN COUNT(*) = 5 THEN 'PASS' ELSE 'FAIL' END AS result +FROM ( + VALUES + (TINYINT '1', 'Catering Plus'), + (TINYINT '2', 'Dave''s Discos'), + (TINYINT '3', 'Entertainment tonight'), + (TINYINT '4', 'Ice Ice Baby'), + (TINYINT '5', 'Party Animals') +) AS expected (supplier_id, name) +JOIN SUPPLIER + ON SUPPLIER.supplier_id = expected.supplier_id + AND SUPPLIER.name = expected.name; + + +-- T07: Every invoice due_date falls on the last day of its month +-- (no rows should appear in the WHERE clause if all dates are correct) +SELECT 'T07: All invoice due_dates are last day of month' AS test_name, + CASE WHEN COUNT(*) = 0 THEN 'PASS' ELSE 'FAIL' END AS result +FROM INVOICE +WHERE due_date <> date_add('day', -1, + date_add('month', 1, + date_trunc('month', due_date))); + + +-- T08: Alex Jacobson (employee_id = 3) total expenses = 1682.00 +-- (6.50*14 + 11.00*20 + 22.00*18 + 13.00*75 = 91 + 220 + 396 + 975) +SELECT 'T08: Alex Jacobson total expenses = 1682.00' AS test_name, + CASE + WHEN CAST(SUM(unit_price * quantity) AS DECIMAL(10, 2)) = DECIMAL '1682.00' + THEN 'PASS' ELSE 'FAIL' + END AS result +FROM EXPENSE +WHERE employee_id = 3; + + +-- T09: calculate_largest_expensors returns exactly 1 row: Alex Jacobson, 1682.00 +SELECT 'T09: Largest expensors — only Alex, 1682.00' AS test_name, + CASE + WHEN COUNT(*) = 1 + AND MIN(employee_id) = 3 + AND MIN(total_expensed) = DECIMAL '1682.00' + THEN 'PASS' ELSE 'FAIL' + END AS result +FROM ( + SELECT + e.employee_id, + CAST(SUM(ex.unit_price * ex.quantity) AS DECIMAL(10, 2)) AS total_expensed + FROM EMPLOYEE e + JOIN EXPENSE ex ON e.employee_id = ex.employee_id + GROUP BY e.employee_id + HAVING SUM(ex.unit_price * ex.quantity) > 1000 +) top_expensors; + + +-- T10: Manager cycle detection returns exactly 3 employees: {1, 2, 4} +-- (Ian -> Darren -> Umberto -> Ian) +SELECT 'T10: Cycle detection finds employees {1, 2, 4}' AS test_name, + CASE + WHEN COUNT(*) = 3 + AND SUM(employee_id) = 7 -- 1 + 2 + 4 = 7 + THEN 'PASS' ELSE 'FAIL' + END AS result +FROM ( + WITH RECURSIVE manager_chain(start_id, current_id, path, visited) AS ( + SELECT + employee_id AS start_id, + manager_id AS current_id, + CAST(employee_id AS VARCHAR) || ' -> ' || CAST(manager_id AS VARCHAR) AS path, + ARRAY[employee_id] AS visited + FROM EMPLOYEE + WHERE manager_id IS NOT NULL + UNION ALL + SELECT + mc.start_id, + e.manager_id, + mc.path || ' -> ' || CAST(e.manager_id AS VARCHAR), + mc.visited || ARRAY[mc.current_id] + FROM manager_chain mc + JOIN EMPLOYEE e ON mc.current_id = e.employee_id + WHERE e.manager_id IS NOT NULL + AND mc.current_id <> mc.start_id + AND NOT contains(mc.visited, mc.current_id) + ) + SELECT start_id AS employee_id + FROM manager_chain + WHERE current_id = start_id +) cycle_results; + + +-- T11: Catering Plus (supplier_id = 1) payment plan matches README example: +-- 3 payments summing to 3500.00, with amounts 1500.00, 1500.00, 500.00 +SELECT 'T11: Catering Plus plan [1500, 1500, 500]' AS test_name, + CASE + WHEN COUNT(*) = 3 + AND SUM(payment_amount) = DECIMAL '3500.00' + AND MAX(payment_amount) = DECIMAL '1500.00' + AND MIN(payment_amount) = DECIMAL '500.00' + THEN 'PASS' ELSE 'FAIL' + END AS result +FROM ( + WITH + inv_sched AS ( + SELECT supplier_id, invoice_ammount, due_date, + date_diff('month', + date_trunc('month', current_date), + date_trunc('month', due_date)) AS num_payments + FROM INVOICE + WHERE supplier_id = 1 + ), + pay_series AS ( + SELECT + s.supplier_id, s.invoice_ammount, s.num_payments, k, + CASE + WHEN k = s.num_payments + THEN s.invoice_ammount + - CAST((s.num_payments - 1) AS DECIMAL(10, 4)) + * ROUND(CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), 2) + ELSE ROUND(CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), 2) + END AS actual_payment, + date_add('day', -1, + date_add('month', k, + date_trunc('month', current_date))) AS payment_date + FROM inv_sched s + CROSS JOIN UNNEST(sequence(1, s.num_payments)) AS t(k) + ) + SELECT + CAST(SUM(actual_payment) AS DECIMAL(8, 2)) AS payment_amount + FROM pay_series + GROUP BY supplier_id, payment_date +) catering_months; + + +-- T12: Every supplier's final payment closes their balance to exactly 0.00 +-- (5 suppliers should all appear with balance_outstanding = 0.00 on last payment) +SELECT 'T12: All suppliers close balance to 0.00' AS test_name, + CASE WHEN COUNT(*) = 5 THEN 'PASS' ELSE 'FAIL' END AS result +FROM ( + WITH + inv_sched AS ( + SELECT supplier_id, invoice_ammount, due_date, + date_diff('month', + date_trunc('month', current_date), + date_trunc('month', due_date)) AS num_payments + FROM INVOICE + ), + pay_series AS ( + SELECT + s.supplier_id, s.invoice_ammount, s.num_payments, k, + CASE + WHEN k = s.num_payments + THEN s.invoice_ammount + - CAST((s.num_payments - 1) AS DECIMAL(10, 4)) + * ROUND(CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), 2) + ELSE ROUND(CAST(s.invoice_ammount AS DECIMAL(10, 4)) + / CAST(s.num_payments AS DECIMAL(10, 4)), 2) + END AS actual_payment, + date_add('day', -1, + date_add('month', k, + date_trunc('month', current_date))) AS payment_date + FROM inv_sched s + CROSS JOIN UNNEST(sequence(1, s.num_payments)) AS t(k) + ), + monthly_pay AS ( + SELECT supplier_id, payment_date, + CAST(SUM(actual_payment) AS DECIMAL(8, 2)) AS payment_amount + FROM pay_series + GROUP BY supplier_id, payment_date + ), + sup_total AS ( + SELECT supplier_id, + CAST(SUM(invoice_ammount) AS DECIMAL(8, 2)) AS total_balance + FROM INVOICE + GROUP BY supplier_id + ), + with_balance AS ( + SELECT + mp.supplier_id, + CAST( + st.total_balance + - SUM(mp.payment_amount) OVER ( + PARTITION BY mp.supplier_id + ORDER BY mp.payment_date + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(8, 2)) AS balance_outstanding, + ROW_NUMBER() OVER ( + PARTITION BY mp.supplier_id + ORDER BY mp.payment_date DESC + ) AS rn + FROM monthly_pay mp + JOIN sup_total st ON mp.supplier_id = st.supplier_id + ) + SELECT supplier_id + FROM with_balance + WHERE rn = 1 + AND balance_outstanding = DECIMAL '0.00' +) final_balances;