diff --git a/calculate_largest_expensors.sql b/calculate_largest_expensors.sql index e69de29..f763a90 100644 --- a/calculate_largest_expensors.sql +++ b/calculate_largest_expensors.sql @@ -0,0 +1,23 @@ +USE memory.default; + +-- NOTE: employee and expense tables must be created first +-- Aggregate first, then join to minimise rows being joined +-- Report employees who have expensed more than 1000 in total +-- LEFT JOIN on manager to handle employees without a manager (e.g. CEO) +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, + ex.total_expensed_amount +FROM ( + SELECT + employee_id, + SUM(unit_price * quantity) AS total_expensed_amount + FROM expense + GROUP BY employee_id + HAVING SUM(unit_price * quantity) > 1000 +) ex +INNER JOIN employee e ON e.employee_id = ex.employee_id +LEFT JOIN employee m ON m.employee_id = e.manager_id +ORDER BY ex.total_expensed_amount DESC; \ No newline at end of file diff --git a/create_employees.sql b/create_employees.sql index e69de29..620be23 100644 --- a/create_employees.sql +++ b/create_employees.sql @@ -0,0 +1,29 @@ +USE memory.default; + +-- Drop table if exists to allow re-running the script +DROP TABLE IF EXISTS employee; + +-- Create the EMPLOYEE table based on hr/employee_index.csv +-- employee_id and manager_id are TINYINT as per requirements +CREATE TABLE employee +( + employee_id TINYINT, + first_name VARCHAR, + last_name VARCHAR, + job_title VARCHAR, + manager_id TINYINT +); + +-- Insert all employees from hr/employee_index.csv +INSERT INTO employee +VALUES + (1, 'Ian', 'James', 'CEO', 4), + (2, 'Umberto', 'Torrielli', 'CSO', 1), + (3, 'Alex', 'Jacobson', 'MD EMEA', 2), + (4, 'Darren', 'Poynton', 'CFO', 2), + (5, 'Tim', 'Beard', 'MD APAC', 2), + (6, 'Gemma', 'Dodd', 'COS', 1), + (7, 'Lisa', 'Platten', 'CHR', 6), + (8, 'Stefano', 'Camisaca', 'GM Activation', 2), + (9, 'Andrea', 'Ghibaudi', 'MD NAM', 2) +; \ No newline at end of file diff --git a/create_expenses.sql b/create_expenses.sql index e69de29..41ef6d7 100644 --- a/create_expenses.sql +++ b/create_expenses.sql @@ -0,0 +1,34 @@ +USE memory.default; + +-- NOTE: employee table must be created first (see create_employees.sql) + +-- Drop table if exists to allow re-running the script +DROP TABLE IF EXISTS expense; + +-- Create the EXPENSE table +CREATE TABLE expense ( + employee_id TINYINT, + unit_price DECIMAL(8, 2), + quantity TINYINT +); + +-- Insert expenses from finance/receipts_from_last_night +-- Mapping employee names to IDs via INNER JOIN on employee table +-- Expenses without a matching employee are excluded +INSERT INTO expense +SELECT + e.employee_id, + r.unit_price, + r.quantity +FROM ( + VALUES + ('Alex Jacobson', DECIMAL '6.50', TINYINT '14'), + ('Alex Jacobson', DECIMAL '11.00', TINYINT '20'), + ('Alex Jacobson', DECIMAL '22.00', TINYINT '18'), + ('Alex Jacobson', DECIMAL '13.00', TINYINT '75'), + ('Andrea Ghibaudi', DECIMAL '300.00', TINYINT '1'), + ('Darren Poynton', DECIMAL '40.00', TINYINT '9'), + ('Umberto Torrielli', DECIMAL '17.50', TINYINT '4') +) AS r(employee_name, unit_price, quantity) +INNER JOIN employee e + ON e.first_name || ' ' || e.last_name = r.employee_name; \ No newline at end of file diff --git a/create_invoices.sql b/create_invoices.sql index e69de29..03816bb 100644 --- a/create_invoices.sql +++ b/create_invoices.sql @@ -0,0 +1,50 @@ +USE memory.default; + +DROP TABLE IF EXISTS invoice; +DROP TABLE IF EXISTS supplier; +DROP TABLE IF EXISTS invoices_raw; + +-- Raw invoice data exactly as in finance/invoices_due, no modifications +CREATE TABLE invoices_raw ( + company_name VARCHAR, + invoice_amount DECIMAL(8, 2), + months_due TINYINT +); + +INSERT INTO invoices_raw VALUES + ('Catering Plus', DECIMAL '2000.00', 2), + ('Catering Plus', DECIMAL '1500.00', 3), + ('Dave''s Discos', DECIMAL '500.00', 1), + ('Entertainment Tonight', DECIMAL '6000.00', 3), + ('Ice Ice Baby', DECIMAL '4000.00', 6), + ('Party Animals', DECIMAL '6000.00', 3); + +-- Create supplier table +-- Deduplicate first, then apply RANK() for supplier_id +CREATE TABLE supplier ( + supplier_id TINYINT, + name VARCHAR +); + +INSERT INTO supplier +SELECT + CAST(RANK() OVER (ORDER BY company_name asc) AS TINYINT) AS supplier_id, + company_name AS name +FROM (SELECT DISTINCT company_name FROM invoices_raw); + +-- Create invoice table +-- invoice_ammount is intentional typo from README spec +CREATE TABLE invoice ( + supplier_id TINYINT, + invoice_ammount DECIMAL(8, 2), + due_date DATE +); + +INSERT INTO invoice +SELECT + s.supplier_id, + r.invoice_amount, + date_trunc('month', current_date + r.months_due * interval '1' month) + + interval '1' month - interval '1' day AS due_date +FROM invoices_raw r +INNER JOIN supplier s ON s.name = r.company_name; \ No newline at end of file diff --git a/find_manager_cycles.sql b/find_manager_cycles.sql index e69de29..27967f3 100644 --- a/find_manager_cycles.sql +++ b/find_manager_cycles.sql @@ -0,0 +1,35 @@ +USE memory.default; + +-- NOTE: employee table must be created first (see create_employees.sql) +-- Find cycles in the manager approval hierarchy using recursive CTE +-- A cycle exists when an employee appears as their own ancestor in the manager chain +-- Results show each employee in a cycle and the full cycle chain as comma-separated employee_ids +WITH RECURSIVE manager_chain(start_id, manager_id, chain, depth) AS ( + -- Base case: start traversal from each employee + SELECT + employee_id AS start_id, + manager_id, + CAST(employee_id AS VARCHAR) AS chain, + 1 AS depth + FROM employee + + UNION ALL + + -- Recursive case: follow manager chain upward + -- Stop if employee already appears in chain (cycle detected) or depth limit reached + SELECT + mc.start_id, + e.manager_id, + mc.chain || ',' || CAST(e.employee_id AS VARCHAR), + mc.depth + 1 + FROM manager_chain mc + INNER JOIN employee e ON e.employee_id = mc.manager_id + WHERE mc.depth < 10 + AND POSITION(CAST(e.employee_id AS VARCHAR) IN mc.chain) = 0 +) +-- Return only employees where traversal loops back to the start +SELECT + start_id AS employee_id, + chain +FROM manager_chain +WHERE manager_id = start_id; \ No newline at end of file diff --git a/generate_supplier_payment_plans.sql b/generate_supplier_payment_plans.sql index e69de29..e1314b2 100644 --- a/generate_supplier_payment_plans.sql +++ b/generate_supplier_payment_plans.sql @@ -0,0 +1,55 @@ +USE memory.default; + +-- NOTE: invoice and supplier tables must be created first +-- Generate monthly payment plan per supplier +-- Step 1: explode each invoice into monthly payments +-- floor rate for all months except last to avoid rounding errors +-- last month rate = remainder to ensure exact total +-- Step 2: group by supplier + payment_date to sum rates across multiple invoices +-- Step 3: calculate balance_outstanding using window function +WITH exploded AS ( + SELECT + s.supplier_id, + s.name AS supplier_name, + i.invoice_ammount, + date_diff('month', date_trunc('month', current_date), i.due_date) AS months_due, + month_number, + date_trunc('month', current_date + month_number * interval '1' month) + + interval '1' month - interval '1' day AS payment_date, + CASE + -- All months except last: floor to avoid rounding up + WHEN month_number < date_diff('month', date_trunc('month', current_date), i.due_date) + THEN CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + -- Last month: remainder to ensure invoice total is exact + ELSE i.invoice_ammount - CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + * (date_diff('month', date_trunc('month', current_date), i.due_date) - 1) + END AS monthly_rate + FROM invoice i + INNER JOIN supplier s ON s.supplier_id = i.supplier_id + CROSS JOIN UNNEST(SEQUENCE(1, date_diff('month', date_trunc('month', current_date), i.due_date))) AS t(month_number) +), +monthly AS ( + -- Group by payment_date (not month_number) to correctly sum rates + -- across multiple invoices for the same supplier in the same month + SELECT + supplier_id, + supplier_name, + payment_date, + SUM(monthly_rate) AS payment_amount + FROM exploded + GROUP BY supplier_id, supplier_name, payment_date +) +SELECT + supplier_id, + supplier_name, + payment_amount, + -- Balance outstanding = total supplier amount minus cumulative payments so far + SUM(payment_amount) OVER (PARTITION BY supplier_id) - + SUM(payment_amount) OVER ( + PARTITION BY supplier_id + ORDER BY payment_date + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS balance_outstanding, + payment_date +FROM monthly +ORDER BY supplier_id, payment_date; \ No newline at end of file diff --git a/tests.sql b/tests.sql new file mode 100644 index 0000000..bb51563 --- /dev/null +++ b/tests.sql @@ -0,0 +1,175 @@ +USE memory.default; + +-- NOTE: all tables must be created first (see create_employees.sql, create_expenses.sql, create_invoices.sql) +-- Data integrity and business logic tests +-- Each test should return 0 rows if data is valid + +-- Test 1: Every expense must have a valid employee_id + SELECT 'FAIL: expense has invalid employee_id' AS test, COUNT(*) AS failures + FROM expense ex + LEFT JOIN employee e ON e.employee_id = ex.employee_id + WHERE e.employee_id IS NULL +UNION ALL + + -- Test 2: Every invoice must have a valid supplier_id + SELECT 'FAIL: invoice has invalid supplier_id' AS test, COUNT(*) AS failures + FROM invoice i + LEFT JOIN supplier s ON s.supplier_id = i.supplier_id + WHERE s.supplier_id IS NULL +UNION ALL + + -- Test 3: No NULL employee_id in employee table + SELECT 'FAIL: employee has NULL employee_id' AS test, COUNT(*) AS failures + FROM employee + WHERE employee_id IS NULL +UNION ALL + + -- Test 4: No NULL manager_id in employee table (except CEO) + SELECT 'FAIL: employee has NULL manager_id' AS test, COUNT(*) AS failures + FROM employee + WHERE manager_id IS NULL +UNION ALL + + -- Test 5: unit_price and quantity must be greater than 0 + SELECT 'FAIL: expense has unit_price or quantity <= 0' AS test, COUNT(*) AS failures + FROM expense + WHERE unit_price <= 0 OR quantity <= 0 +UNION ALL + + -- Test 6: invoice_ammount must be greater than 0 + SELECT 'FAIL: invoice has invoice_ammount <= 0' AS test, COUNT(*) AS failures + FROM invoice + WHERE invoice_ammount <= 0 +UNION ALL + + -- Test 7: due_date must be in the future + SELECT 'FAIL: invoice has due_date in the past' AS test, COUNT(*) AS failures + FROM invoice + WHERE due_date < current_date +UNION ALL + + -- Test 8: payment plan total must equal sum of invoices per supplier + SELECT 'FAIL: payment plan total does not match invoice total' AS test, COUNT(*) AS failures + FROM ( + SELECT i.supplier_id + FROM ( + SELECT supplier_id, SUM(invoice_ammount) AS total_invoiced + FROM invoice + GROUP BY supplier_id + ) i + JOIN ( + WITH exploded AS ( + SELECT + s.supplier_id, + date_diff('month', date_trunc('month', current_date), i.due_date) AS months_due, + month_number, + CASE + WHEN month_number < date_diff('month', date_trunc('month', current_date), i.due_date) + THEN CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + ELSE i.invoice_ammount - CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + * (date_diff('month', date_trunc('month', current_date), i.due_date) - 1) + END AS monthly_rate + FROM invoice i + INNER JOIN supplier s ON s.supplier_id = i.supplier_id + CROSS JOIN UNNEST(SEQUENCE(1, date_diff('month', date_trunc('month', current_date), i.due_date))) AS t(month_number) + ) +SELECT supplier_id, SUM(monthly_rate) AS total_planned +FROM exploded +GROUP BY supplier_id +) p ON i.supplier_id = p.supplier_id + WHERE ABS +(i.total_invoiced - p.total_planned) > DECIMAL '0.01' +) +union all +-- Test 9: last payment balance_outstanding must be 0 for each supplier +SELECT 'FAIL: last payment balance is not 0 for some supplier' AS test, COUNT(*) AS failures +FROM ( + WITH exploded AS +( + SELECT + s.supplier_id, + date_diff('month', date_trunc('month', current_date), i.due_date) AS months_due, + month_number, + date_trunc('month', current_date + month_number * interval '1' +month) + + interval '1' month - interval '1' day AS payment_date, + CASE + WHEN month_number < date_diff +('month', date_trunc +('month', current_date), i.due_date) + THEN CAST +(FLOOR +(i.invoice_ammount / date_diff +('month', date_trunc +('month', current_date), i.due_date)) AS DECIMAL +(8,2)) + ELSE i.invoice_ammount - CAST +(FLOOR +(i.invoice_ammount / date_diff +('month', date_trunc +('month', current_date), i.due_date)) AS DECIMAL +(8,2)) + * +(date_diff +('month', date_trunc +('month', current_date), i.due_date) - 1) +END AS monthly_rate + FROM invoice i + INNER JOIN supplier s ON s.supplier_id = i.supplier_id + CROSS JOIN UNNEST +(SEQUENCE +(1, date_diff +('month', date_trunc +('month', current_date), i.due_date))) AS t +(month_number) + ), + monthly AS +( + SELECT supplier_id, payment_date, SUM(monthly_rate) AS payment_amount +FROM exploded +GROUP BY supplier_id, payment_date + ) +, + plan AS +( + SELECT + supplier_id, + payment_date, + SUM(payment_amount) OVER (PARTITION BY supplier_id) - + SUM(payment_amount) OVER ( + PARTITION BY supplier_id + ORDER BY payment_date + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS balance_outstanding, + ROW_NUMBER() OVER (PARTITION BY supplier_id ORDER BY payment_date DESC) AS rn +FROM monthly + ) +SELECT supplier_id +FROM plan +WHERE rn = 1 AND balance_outstanding <> 0 +) +UNION ALL + +-- Test 10: no negative payment amounts in payment plan +SELECT 'FAIL: payment plan has negative payment_amount' AS test, COUNT(*) AS failures +FROM ( + WITH exploded AS +( + SELECT + s.supplier_id, + date_diff('month', date_trunc('month', current_date), i.due_date) AS months_due, + month_number, + CASE + WHEN month_number < date_diff('month', date_trunc('month', current_date), i.due_date) + THEN CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + ELSE i.invoice_ammount - CAST(FLOOR(i.invoice_ammount / date_diff('month', date_trunc('month', current_date), i.due_date)) AS DECIMAL(8,2)) + * (date_diff('month', date_trunc('month', current_date), i.due_date) - 1) + END AS monthly_rate +FROM invoice i + INNER JOIN supplier s ON s.supplier_id = i.supplier_id + CROSS JOIN UNNEST(SEQUENCE(1, date_diff('month', date_trunc('month', current_date), i.due_date))) AS t(month_number) + ) +SELECT supplier_id +FROM exploded +WHERE monthly_rate < 0 +); \ No newline at end of file