Skip to content

Customer/Operations Analysis

Tables

customers — Customers (grade, points, channel)

orders — Orders (status, amount, date)

order_items — Order items (qty, unit price)

products — Products (name, price, stock, brand)

categories — Categories (parent-child hierarchy)

inventory_transactions — Inventory (type, quantity)

customer_grade_history — Grade history (before/after)

complaints — Complaints (type, priority)

staff — Staff (dept, role, manager)

Concepts

RFM Analysis, Cohort, LTV, Inventory ABC Analysis, Safety Stock, CS Performance, CTE + Window Function

1. RFM basics — calculation of key indicators for each customer

The marketing team requested RFM metrics for each customer for customer segmentation. Find the last order date (Recency), total number of orders (Frequency), and total purchase amount (Monetary) for each customer. Excludes canceled/returned orders. Only the top 20 are shown.

Hint 1: - Recency: MAX(ordered_at) - Frequency: COUNT(*) - Monetary: SUM(total_amount) - customers + orders JOIN

Answer
SELECT
    c.id            AS customer_id,
    c.name          AS customer_name,
    c.grade,
    MAX(o.ordered_at)           AS last_order_date,
    COUNT(*)                    AS order_count,
    ROUND(SUM(o.total_amount))  AS total_spent
FROM customers AS c
INNER JOIN orders AS o ON c.id = o.customer_id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
GROUP BY c.id, c.name, c.grade
ORDER BY total_spent DESC
LIMIT 20;

Result (top 7 of 20 rows)

customer_id customer_name grade last_order_date order_count total_spent
226 Allen Snyder VIP 2025-12-21 21:52:24 303 403,448,758.00
97 Jason Rivera VIP 2025-12-28 11:37:58 342 366,385,931.00
162 Brenda Garcia VIP 2025-12-20 10:21:05 249 253,180,338.00
356 Courtney Huff VIP 2025-10-24 16:44:53 223 244,604,910.00
549 Ronald Arellano VIP 2025-12-04 12:11:17 219 235,775,349.00
227 James Banks VIP 2025-12-19 22:54:22 230 234,708,853.00
98 Gabriel Walters VIP 2025-11-29 11:04:23 275 230,165,991.00

2. RFM quartile segment

Divide your customers into quartiles (Q1 to Q4) based on RFM metrics. The more recent the Recency, the higher the score (4), and the larger the Frequency and Monetary, the higher the score (4). Displays each customer's R/F/M score and total score.

Hint 1: - Divide each indicator into 4 equal parts with NTILE(4) - Recency: ORDER BY last_order_date ASC → NTILE 4 is the most recent - Frequency/Monetary: ORDER BY ... ASC → NTILE 4 is the largest - Total score = R + F + M (maximum 12 points)

Answer
WITH rfm_raw AS (
    SELECT
        c.id AS customer_id,
        c.name,
        c.grade,
        MAX(o.ordered_at)           AS last_order_date,
        COUNT(*)                    AS frequency,
        ROUND(SUM(o.total_amount))  AS monetary
    FROM customers AS c
    INNER JOIN orders AS o ON c.id = o.customer_id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY c.id, c.name, c.grade
),
rfm_scored AS (
    SELECT
        customer_id,
        name,
        grade,
        last_order_date,
        frequency,
        monetary,
        NTILE(4) OVER (ORDER BY last_order_date ASC)  AS r_score,
        NTILE(4) OVER (ORDER BY frequency ASC)         AS f_score,
        NTILE(4) OVER (ORDER BY monetary ASC)          AS m_score
    FROM rfm_raw
)
SELECT
    customer_id,
    name,
    grade,
    r_score,
    f_score,
    m_score,
    r_score + f_score + m_score AS rfm_total,
    CASE
        WHEN r_score + f_score + m_score >= 10 THEN 'Champion'
        WHEN r_score + f_score + m_score >= 7  THEN 'Loyal'
        WHEN r_score >= 3 AND f_score <= 2     THEN 'New Customer'
        WHEN r_score <= 2 AND f_score >= 3     THEN 'At Risk'
        ELSE 'Regular'
    END AS segment
FROM rfm_scored
ORDER BY rfm_total DESC
LIMIT 20;

Result (top 7 of 20 rows)

customer_id name grade r_score f_score m_score rfm_total segment
486 Hannah Norton GOLD 4 4 4 12 Champion
10 John Stark GOLD 4 4 4 12 Champion
1490 April Lopez VIP 4 4 4 12 Champion
647 Leslie Mccoy VIP 4 4 4 12 Champion
1241 Cheryl Garcia BRONZE 4 4 4 12 Champion
256 Jessica Craig GOLD 4 4 4 12 Champion
2328 Julie Hamilton VIP 4 4 4 12 Champion

3. Cohort retention analysis

Find the monthly retention (repurchase rate) of customers who made their first purchase in 2023 through cohort analysis. Calculate the retention rate for 0 months (first purchase), 1 month, 2 months, ... 6 months after signing up.

Hint 1: - Cohort = group of customers with the same first purchase month - Month Difference = Order Month - First Purchase Month (in months) - Retention rate = Number of cohort customers who ordered in that month / Total number of cohort customers

Answer
WITH first_purchase AS (
    SELECT
        customer_id,
        SUBSTR(MIN(ordered_at), 1, 7) AS cohort_month
    FROM orders
    WHERE status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY customer_id
    HAVING cohort_month LIKE '2023%'
),
monthly_activity AS (
    SELECT DISTINCT
        fp.customer_id,
        fp.cohort_month,
        SUBSTR(o.ordered_at, 1, 7) AS activity_month,
        (CAST(SUBSTR(o.ordered_at, 1, 4) AS INTEGER) - CAST(SUBSTR(fp.cohort_month, 1, 4) AS INTEGER)) * 12
        + CAST(SUBSTR(o.ordered_at, 6, 2) AS INTEGER) - CAST(SUBSTR(fp.cohort_month, 6, 2) AS INTEGER)
            AS month_offset
    FROM first_purchase AS fp
    INNER JOIN orders AS o ON fp.customer_id = o.customer_id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
),
cohort_size AS (
    SELECT cohort_month, COUNT(DISTINCT customer_id) AS total_customers
    FROM first_purchase
    GROUP BY cohort_month
)
SELECT
    ma.cohort_month,
    cs.total_customers,
    ma.month_offset,
    COUNT(DISTINCT ma.customer_id) AS active_customers,
    ROUND(100.0 * COUNT(DISTINCT ma.customer_id) / cs.total_customers, 1) AS retention_pct
FROM monthly_activity AS ma
INNER JOIN cohort_size AS cs ON ma.cohort_month = cs.cohort_month
WHERE ma.month_offset BETWEEN 0 AND 6
GROUP BY ma.cohort_month, cs.total_customers, ma.month_offset
ORDER BY ma.cohort_month, ma.month_offset;

Result (top 7 of 83 rows)

cohort_month total_customers month_offset active_customers retention_pct
2023-01 21 0 21 100.00
2023-01 21 1 2 9.50
2023-01 21 2 1 4.80
2023-01 21 3 2 9.50
2023-01 21 4 5 23.80
2023-01 21 5 7 33.30
2023-02 31 0 31 100.00

4. Detecting Churning Customers

Classify customers who haven't made a purchase in more than six months since their last order as "churn risk." Find the distribution of churned customers by grade and the average purchase amount before churning. (Base date: 2025-03-31)

Hint 1: - Exit criteria: JULIANDAY('2025-03-31') - JULIANDAY(MAX(ordered_at)) > 180 - GROUP BY by grade - Also displays comparison between churned and active customers

Answer
WITH customer_activity AS (
    SELECT
        c.id            AS customer_id,
        c.name,
        c.grade,
        MAX(o.ordered_at)           AS last_order_date,
        COUNT(*)                    AS order_count,
        ROUND(AVG(o.total_amount))  AS avg_order_value,
        ROUND(JULIANDAY('2025-03-31') - JULIANDAY(MAX(o.ordered_at))) AS days_since_last
    FROM customers AS c
    INNER JOIN orders AS o ON c.id = o.customer_id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY c.id, c.name, c.grade
)
SELECT
    grade,
    CASE WHEN days_since_last > 180 THEN '이탈 위험' ELSE '활성' END AS status,
    COUNT(*) AS customer_count,
    ROUND(AVG(avg_order_value)) AS avg_order_value,
    ROUND(AVG(order_count), 1)  AS avg_orders,
    ROUND(AVG(days_since_last)) AS avg_days_inactive
FROM customer_activity
GROUP BY grade,
    CASE WHEN days_since_last > 180 THEN '이탈 위험' ELSE '활성' END
ORDER BY
    CASE grade WHEN 'VIP' THEN 1 WHEN 'GOLD' THEN 2 WHEN 'SILVER' THEN 3 ELSE 4 END,
    status;

Result (5 rows)

grade status customer_count avg_order_value avg_orders avg_days_inactive
VIP 활성 368 1,437,751.00 38.20 -222.00
GOLD 활성 524 1,112,559.00 15.10 -169.00
SILVER 활성 479 877,935.00 10.70 -133.00
BRONZE 이탈 위험 599 840,118.00 4.50 578.00
BRONZE 활성 823 556,893.00 6.00 -69.00

5. Estimating customer lifetime value (LTV)

Estimate the average Life Time Value (LTV) for each customer tier. LTV = average order value x number of orders per year x average duration of activity (years)

Hint 1: - Activity period = (last order date - first order date) / 365. Processed as a minimum of 1 year - Number of orders per year = Total number of orders / Activity period - Aggregation by grade

Answer
WITH customer_ltv AS (
    SELECT
        c.id,
        c.grade,
        COUNT(*) AS total_orders,
        AVG(o.total_amount) AS avg_order_value,
        MAX(JULIANDAY(o.ordered_at) - JULIANDAY(MIN(o.ordered_at)) OVER (PARTITION BY c.id))
            / 365.0 AS active_years_raw
    FROM customers AS c
    INNER JOIN orders AS o ON c.id = o.customer_id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY c.id, c.grade
),
ltv_calc AS (
    SELECT
        id,
        grade,
        total_orders,
        avg_order_value,
        MAX(active_years_raw, 1.0) AS active_years,
        total_orders / MAX(active_years_raw, 1.0) AS orders_per_year,
        avg_order_value * (total_orders / MAX(active_years_raw, 1.0)) * MAX(active_years_raw, 1.0)
            AS ltv
    FROM customer_ltv
)
SELECT
    grade,
    COUNT(*) AS customer_count,
    ROUND(AVG(avg_order_value))  AS avg_order_value,
    ROUND(AVG(orders_per_year), 1) AS avg_annual_orders,
    ROUND(AVG(active_years), 1)    AS avg_active_years,
    ROUND(AVG(ltv))                AS avg_ltv
FROM ltv_calc
GROUP BY grade
ORDER BY avg_ltv DESC;

6. Comparison of customer quality by subscription channel

Number of customers, average number of orders, average purchase amount by subscription channel (acquisition_channel), Compare VIP/GOLD conversion rates.

Hint 1: - customers.acquisition_channel: organic/search_ad/social/referral/direct - VIP/GOLD conversion rate = Number of VIP+GOLD customers in the channel / Total number of customers - Including customers without orders (LEFT JOIN)

Answer
WITH channel_stats AS (
    SELECT
        COALESCE(c.acquisition_channel, '미확인') AS channel,
        c.id AS customer_id,
        c.grade,
        COUNT(o.id) AS order_count,
        COALESCE(SUM(o.total_amount), 0) AS total_spent
    FROM customers AS c
    LEFT JOIN orders AS o
        ON c.id = o.customer_id
        AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY c.acquisition_channel, c.id, c.grade
)
SELECT
    channel,
    COUNT(*)                        AS customer_count,
    ROUND(AVG(order_count), 1)      AS avg_orders,
    ROUND(AVG(CASE WHEN total_spent > 0 THEN total_spent END)) AS avg_spent,
    ROUND(100.0 * SUM(CASE WHEN grade IN ('VIP', 'GOLD') THEN 1 ELSE 0 END)
        / COUNT(*), 1) AS premium_rate_pct,
    ROUND(100.0 * SUM(CASE WHEN order_count = 0 THEN 1 ELSE 0 END)
        / COUNT(*), 1) AS never_ordered_pct
FROM channel_stats
GROUP BY channel
ORDER BY avg_spent DESC;

Result (5 rows)

channel customer_count avg_orders avg_spent premium_rate_pct never_ordered_pct
organic 1146 8.00 14,523,151.00 19.00 45.90
direct 408 7.30 14,104,863.00 16.40 48.50
referral 708 6.80 12,712,480.00 16.80 45.80
search_ad 1543 6.40 11,875,351.00 17.30 45.70
social 1425 5.60 10,755,623.00 15.50 48.00

7. Tracking grade changes

Using the customer_grade_history table, find the number of customers whose ratings went up and down in 2024. It also displays the number of cases by grade change path (e.g. SILVER → GOLD).

Hint 1: - customer_grade_history: customer_id, old_grade, new_grade, changed_at - Rank order: BRONZE < SILVER < GOLD < VIP - Compare after assigning numbers to grades using a CASE statement

Answer
WITH grade_order AS (
    SELECT
        customer_id,
        old_grade,
        new_grade,
        changed_at,
        CASE old_grade WHEN 'BRONZE' THEN 1 WHEN 'SILVER' THEN 2 WHEN 'GOLD' THEN 3 WHEN 'VIP' THEN 4 END AS old_rank,
        CASE new_grade WHEN 'BRONZE' THEN 1 WHEN 'SILVER' THEN 2 WHEN 'GOLD' THEN 3 WHEN 'VIP' THEN 4 END AS new_rank
    FROM customer_grade_history
    WHERE changed_at LIKE '2024%'
)
SELECT
    old_grade || ' → ' || new_grade AS grade_change,
    CASE
        WHEN new_rank > old_rank THEN '승급'
        WHEN new_rank < old_rank THEN '강등'
        ELSE '유지'
    END AS direction,
    COUNT(*) AS change_count
FROM grade_order
GROUP BY old_grade, new_grade, direction
ORDER BY
    CASE WHEN new_rank > old_rank THEN 1 WHEN new_rank < old_rank THEN 2 ELSE 3 END,
    change_count DESC;

Result (top 7 of 13 rows)

grade_change direction change_count
BRONZE → GOLD 승급 167
BRONZE → SILVER 승급 160
BRONZE → VIP 승급 66
GOLD → VIP 승급 63
SILVER → GOLD 승급 52
SILVER → VIP 승급 37
SILVER → BRONZE 강등 125

8. ABC inventory classification

Sort products ABC based on current inventory amount (stock_qty x cost_price). (A: Top 70%, B: 70~90%, C: Rest) Displays the number of products in each grade and the total inventory amount.

Hint 1: - Inventory amount = stock_qty * cost_price - ABC classification by cumulative ratio (same pattern as ABC in sales analysis) - Only active products are eligible

Answer
WITH inventory_value AS (
    SELECT
        p.id,
        p.name,
        p.stock_qty,
        p.cost_price,
        p.stock_qty * p.cost_price AS stock_value
    FROM products AS p
    WHERE p.is_active = 1
      AND p.stock_qty > 0
),
cumulative AS (
    SELECT
        id, name, stock_qty, cost_price, stock_value,
        SUM(stock_value) OVER (ORDER BY stock_value DESC) AS cum_value,
        SUM(stock_value) OVER () AS total_value
    FROM inventory_value
),
classified AS (
    SELECT *,
        CASE
            WHEN 100.0 * cum_value / total_value <= 70 THEN 'A'
            WHEN 100.0 * cum_value / total_value <= 90 THEN 'B'
            ELSE 'C'
        END AS abc_class
    FROM cumulative
)
SELECT
    abc_class,
    COUNT(*)                 AS product_count,
    ROUND(SUM(stock_value)) AS total_stock_value,
    ROUND(100.0 * SUM(stock_value) / (SELECT SUM(stock_value) FROM inventory_value), 1) AS value_pct,
    ROUND(AVG(stock_qty))    AS avg_stock_qty
FROM classified
GROUP BY abc_class
ORDER BY abc_class;

Result (3 rows)

abc_class product_count total_stock_value value_pct avg_stock_qty
A 46 20,873,678,400.00 69.50 337.00
B 52 6,146,023,700.00 20.50 310.00
C 119 3,010,558,600.00 10.00 233.00

9. Dead stock detection

Find active products that haven't sold at all in the last 6 months. Displays inventory quantity, inventory amount, and last sale date.

Hint 1: - Check last 6 months orders in order_items + orders - Product with NULL order after LEFT JOIN = No sale - or utilize the NOT EXISTS pattern

Answer
WITH last_sale AS (
    SELECT
        oi.product_id,
        MAX(o.ordered_at) AS last_sold_at
    FROM order_items AS oi
    INNER JOIN orders AS o ON oi.order_id = o.id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY oi.product_id
)
SELECT
    p.name         AS product_name,
    cat.name       AS category,
    p.stock_qty,
    ROUND(p.stock_qty * p.cost_price) AS stock_value,
    ls.last_sold_at,
    ROUND(JULIANDAY('2025-03-31') - JULIANDAY(ls.last_sold_at)) AS days_since_last_sale
FROM products AS p
INNER JOIN categories AS cat ON p.category_id = cat.id
LEFT JOIN last_sale AS ls ON p.id = ls.product_id
WHERE p.is_active = 1
  AND p.stock_qty > 0
  AND (ls.last_sold_at IS NULL OR ls.last_sold_at < DATE('2025-03-31', '-6 months'))
ORDER BY stock_value DESC
LIMIT 20;

10. Reorder Point Calculation

Calculate when to reorder based on the average daily shipment volume for each product. Reorder point = average daily shipment x lead time (7 days) x safety factor (1.5) Find products that are currently in stock at or below the reorder point.

Hint 1: - Last 3 months shipment volume: inventory_transactions to type = 'outbound' - Average daily shipment volume = Total shipment volume for 3 months / 90 - Re-order time = average daily shipment x 7 x 1.5

Answer
WITH daily_demand AS (
    SELECT
        product_id,
        ABS(SUM(quantity)) / 90.0 AS avg_daily_demand
    FROM inventory_transactions
    WHERE type = 'outbound'
      AND created_at >= DATE('2025-03-31', '-3 months')
    GROUP BY product_id
),
reorder_calc AS (
    SELECT
        p.id,
        p.name,
        p.stock_qty,
        ROUND(dd.avg_daily_demand, 2) AS avg_daily_demand,
        ROUND(dd.avg_daily_demand * 7 * 1.5) AS reorder_point,
        ROUND(p.stock_qty / NULLIF(dd.avg_daily_demand, 0)) AS days_of_stock
    FROM products AS p
    INNER JOIN daily_demand AS dd ON p.id = dd.product_id
    WHERE p.is_active = 1
)
SELECT
    name AS product_name,
    stock_qty,
    avg_daily_demand,
    reorder_point,
    days_of_stock,
    CASE
        WHEN stock_qty <= reorder_point THEN '즉시 발주'
        WHEN days_of_stock <= 14 THEN '발주 예정'
        ELSE '충분'
    END AS order_status
FROM reorder_calc
WHERE stock_qty <= reorder_point
ORDER BY days_of_stock ASC;

Result (1 rows)

product_name stock_qty avg_daily_demand reorder_point days_of_stock order_status
Arctic Freezer 36 A-RGB White 0 0.09 1.00 0.0 즉시 발주

11. Inventory turnover by category

Calculate inventory turnover by category. Inventory Turnover = Annual Cost of Goods Sold (COGS) / Average Inventory Amount

Hint 1: - Cost of goods sold (COGS): SUM(oi.quantity * p.cost_price) (sales in 2024) - Average inventory amount: AVG(stock_qty * cost_price) (current time) - The higher the turnover rate, the faster inventory is sold.

Answer
WITH cogs_2024 AS (
    SELECT
        p.category_id,
        SUM(oi.quantity * p.cost_price) AS annual_cogs
    FROM order_items AS oi
    INNER JOIN orders AS o ON oi.order_id = o.id
    INNER JOIN products AS p ON oi.product_id = p.id
    WHERE o.ordered_at >= '2024-01-01' AND o.ordered_at < '2025-01-01'
      AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY p.category_id
),
avg_inventory AS (
    SELECT
        category_id,
        AVG(stock_qty * cost_price) AS avg_stock_value,
        SUM(stock_qty * cost_price) AS total_stock_value,
        COUNT(*) AS product_count
    FROM products
    WHERE is_active = 1
    GROUP BY category_id
)
SELECT
    cat.name AS category,
    ai.product_count,
    ROUND(cg.annual_cogs) AS annual_cogs,
    ROUND(ai.total_stock_value) AS current_stock_value,
    ROUND(cg.annual_cogs / NULLIF(ai.avg_stock_value, 0), 1) AS turnover_rate,
    ROUND(365.0 / NULLIF(cg.annual_cogs / NULLIF(ai.avg_stock_value, 0), 0)) AS days_in_inventory
FROM avg_inventory AS ai
INNER JOIN cogs_2024 AS cg ON ai.category_id = cg.category_id
INNER JOIN categories AS cat ON ai.category_id = cat.id
ORDER BY turnover_rate DESC;

Result (top 7 of 38 rows)

category product_count annual_cogs current_stock_value turnover_rate days_in_inventory
Wired 2 10,751,300.00 1,612,200.00 13.30 27.00
Case 10 103,892,500.00 261,356,300.00 4.00 92.00
Speakers/Headsets 9 164,632,200.00 416,492,100.00 3.60 103.00
Gaming 8 76,210,600.00 187,113,000.00 3.30 112.00
DDR5 8 115,931,100.00 308,598,300.00 3.00 121.00
DDR4 5 41,246,500.00 69,961,100.00 2.90 124.00
SSD 6 122,676,700.00 258,240,800.00 2.90 128.00

12. Monthly trend of inventory arrival and departure

Calculate monthly receipts, shipments, net changes, and month-end cumulative inventory for 2024. This is based on the sum of all products.

Hint 1: - type in inventory_transactions: inbound (positive), outbound (negative) - Separation of receipt/delivery through conditional counting - Cumulative sum: SUM(...) OVER (ORDER BY year_month)

Answer
WITH monthly_flow AS (
    SELECT
        SUBSTR(created_at, 1, 7) AS year_month,
        SUM(CASE WHEN quantity > 0 THEN quantity ELSE 0 END) AS inbound_qty,
        SUM(CASE WHEN quantity < 0 THEN ABS(quantity) ELSE 0 END) AS outbound_qty,
        SUM(quantity) AS net_change
    FROM inventory_transactions
    WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
    GROUP BY SUBSTR(created_at, 1, 7)
)
SELECT
    year_month,
    inbound_qty,
    outbound_qty,
    net_change,
    SUM(net_change) OVER (ORDER BY year_month) AS cumulative_change
FROM monthly_flow
ORDER BY year_month;

Result (top 7 of 12 rows)

year_month inbound_qty outbound_qty net_change cumulative_change
2024-01 2007 199 1808 1808
2024-02 943 163 780 2588
2024-03 3536 248 3288 5876
2024-04 3528 122 3406 9282
2024-05 3408 253 3155 12,437
2024-06 2688 171 2517 14,954
2024-07 2900 138 2762 17,716

13. Inquiry processing time analysis

Find the average processing time, median (approximate), and SLA compliance rate by inquiry type (category) in 2024. SLA: Resolution within 3 days for general inquiries, 1 day for claims, 0.5 days for emergencies

Hint 1: - Processing time: JULIANDAY(resolved_at) - JULIANDAY(created_at) - Median approximation: 50th percentile → use NTILE instead of PERCENTILE - SLA standards are applied differently depending on priority

Answer
WITH resolution_times AS (
    SELECT
        category,
        priority,
        JULIANDAY(resolved_at) - JULIANDAY(created_at) AS resolution_days,
        CASE priority
            WHEN 'urgent' THEN 0.5
            WHEN 'high'   THEN 1.0
            WHEN 'medium' THEN 2.0
            ELSE 3.0
        END AS sla_days
    FROM complaints
    WHERE created_at LIKE '2024%'
      AND resolved_at IS NOT NULL
)
SELECT
    category,
    COUNT(*) AS resolved_count,
    ROUND(AVG(resolution_days), 2) AS avg_resolution_days,
    ROUND(MIN(resolution_days), 2) AS min_days,
    ROUND(MAX(resolution_days), 2) AS max_days,
    ROUND(100.0 * SUM(CASE WHEN resolution_days <= sla_days THEN 1 ELSE 0 END)
        / COUNT(*), 1) AS sla_compliance_pct
FROM resolution_times
GROUP BY category
ORDER BY sla_compliance_pct ASC;

Result (7 rows)

category resolved_count avg_resolution_days min_days max_days sla_compliance_pct
general_inquiry 195 1.92 0.04 4.00 76.90
exchange_request 34 1.25 0.08 3.96 88.20
price_inquiry 73 1.62 0.13 3.79 90.40
delivery_issue 109 0.73 0.04 4.00 95.40
refund_request 64 0.91 0.04 3.46 96.90
wrong_item 40 0.78 0.08 3.21 97.50
product_defect 69 0.7 0.04 3.46 98.60

14. Return reason analysis and trend

Find the number, rate, and average refund amount by return reason in 2024. We also check quarterly trends.

Hint 1: - returns table: reason, refund_amount, requested_at - Write overall ratio + quarterly trend as a separate query

Answer
-- 사유별 전체 통계
SELECT
    reason,
    COUNT(*)                     AS return_count,
    ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct,
    ROUND(AVG(refund_amount))    AS avg_refund,
    ROUND(SUM(refund_amount))    AS total_refund
FROM returns
WHERE requested_at >= '2024-01-01' AND requested_at < '2025-01-01'
GROUP BY reason
ORDER BY return_count DESC;

-- 분기별 추이
SELECT
    'Q' || ((CAST(SUBSTR(requested_at, 6, 2) AS INTEGER) + 2) / 3) AS quarter,
    reason,
    COUNT(*) AS return_count
FROM returns
WHERE requested_at >= '2024-01-01' AND requested_at < '2025-01-01'
GROUP BY (CAST(SUBSTR(requested_at, 6, 2) AS INTEGER) + 2) / 3, reason
ORDER BY quarter, return_count DESC;

15. Comparison of performance by CS employee

Compare the number of inquiries handled by each CS employee, resolution rate, average processing time, and customer satisfaction (rate without compensation). The difference from the overall average is also displayed.

Hint 1: - complaints.staff_idstaff JOIN - Display overall averages in the same row with the window function AVG(...) OVER () - Customer satisfaction proxy indicator: Proportion of compensation_type that is NULL or 'none'

Answer
WITH staff_metrics AS (
    SELECT
        s.name AS staff_name,
        COUNT(*) AS case_count,
        SUM(CASE WHEN comp.status IN ('resolved', 'closed') THEN 1 ELSE 0 END) AS resolved_count,
        AVG(CASE
            WHEN comp.resolved_at IS NOT NULL
            THEN JULIANDAY(comp.resolved_at) - JULIANDAY(comp.created_at)
        END) AS avg_resolution_days,
        100.0 * SUM(CASE WHEN COALESCE(comp.compensation_type, 'none') = 'none' THEN 1 ELSE 0 END)
            / COUNT(*) AS no_compensation_pct
    FROM complaints AS comp
    INNER JOIN staff AS s ON comp.staff_id = s.id
    WHERE comp.created_at LIKE '2024%'
    GROUP BY s.id, s.name
)
SELECT
    staff_name,
    case_count,
    ROUND(100.0 * resolved_count / case_count, 1) AS resolution_rate,
    ROUND(avg_resolution_days, 2) AS avg_days,
    ROUND(no_compensation_pct, 1) AS satisfaction_proxy_pct,
    ROUND(AVG(case_count) OVER (), 1) AS team_avg_cases,
    ROUND(AVG(avg_resolution_days) OVER (), 2) AS team_avg_days
FROM staff_metrics
ORDER BY resolution_rate DESC;

Result (5 rows)

staff_name case_count resolution_rate avg_days satisfaction_proxy_pct team_avg_cases team_avg_days
Jaime Phelps 123 95.10 1.36 67.50 123.20 1.29
Jonathan Smith 118 94.90 1.29 60.20 123.20 1.29
Michael Mcguire 115 94.80 1.32 63.50 123.20 1.29
Michael Thomas 131 94.70 1.19 57.30 123.20 1.29
Nicole Hamilton 129 94.60 1.29 72.10 123.20 1.29

16. Escalation analysis

Analyze the characteristics of escalated (escalated = 1) inquiries. Understand which types, channels, and priorities are generating the most escalations.

Hint 1: - The thing that is complaints.escalated = 1 - Escalation rate = Number of escalations / Total number of cases - Aggregated by category, channel, and priority

Answer
SELECT
    category,
    channel,
    priority,
    COUNT(*) AS total_count,
    SUM(escalated) AS escalated_count,
    ROUND(100.0 * SUM(escalated) / COUNT(*), 1) AS escalation_rate_pct,
    ROUND(AVG(CASE
        WHEN escalated = 1 AND resolved_at IS NOT NULL
        THEN JULIANDAY(resolved_at) - JULIANDAY(created_at)
    END), 2) AS escalated_avg_days
FROM complaints
WHERE created_at LIKE '2024%'
GROUP BY category, channel, priority
HAVING COUNT(*) >= 5
ORDER BY escalation_rate_pct DESC
LIMIT 15;

Result (top 7 of 15 rows)

category channel priority total_count escalated_count escalation_rate_pct escalated_avg_days
wrong_item website high 8 3 37.50 0.4
product_defect website medium 8 2 25.00 0.92
refund_request phone medium 9 2 22.20 0.6
product_defect email medium 5 1 20.00 0.33
product_defect phone urgent 5 1 20.00 0.13
wrong_item phone medium 5 1 20.00 1.04
exchange_request website medium 6 1 16.70 1.33

17. CS incidence rate by product

Find the CS (inquiries + returns) incidence rate of the top 30 products in sales. Products with a high incidence of CS require quality improvement.

Hint 1: - Ratio of inquiries/returns to number of sales - Number of sales by product in order_items - Number of product-related CS cases in complaints + returns - Complaints are connected to order_id → order_items → product_id

Answer
WITH top_products AS (
    SELECT
        p.id AS product_id,
        p.name AS product_name,
        SUM(oi.quantity) AS total_sold
    FROM order_items AS oi
    INNER JOIN orders AS o ON oi.order_id = o.id
    INNER JOIN products AS p ON oi.product_id = p.id
    WHERE o.status NOT IN ('cancelled')
      AND o.ordered_at >= '2024-01-01' AND o.ordered_at < '2025-01-01'
    GROUP BY p.id, p.name
    ORDER BY total_sold DESC
    LIMIT 30
),
complaint_counts AS (
    SELECT
        oi.product_id,
        COUNT(DISTINCT comp.id) AS complaint_count
    FROM complaints AS comp
    INNER JOIN orders AS o ON comp.order_id = o.id
    INNER JOIN order_items AS oi ON o.id = oi.order_id
    WHERE comp.created_at LIKE '2024%'
    GROUP BY oi.product_id
),
return_counts AS (
    SELECT
        oi.product_id,
        COUNT(DISTINCT ret.id) AS return_count
    FROM returns AS ret
    INNER JOIN order_items AS oi ON ret.order_id = oi.order_id
    WHERE ret.requested_at LIKE '2024%'
    GROUP BY oi.product_id
)
SELECT
    tp.product_name,
    tp.total_sold,
    COALESCE(cc.complaint_count, 0) AS complaints,
    COALESCE(rc.return_count, 0) AS returns,
    COALESCE(cc.complaint_count, 0) + COALESCE(rc.return_count, 0) AS total_cs,
    ROUND(100.0 * (COALESCE(cc.complaint_count, 0) + COALESCE(rc.return_count, 0))
        / tp.total_sold, 1) AS cs_rate_pct
FROM top_products AS tp
LEFT JOIN complaint_counts AS cc ON tp.product_id = cc.product_id
LEFT JOIN return_counts AS rc ON tp.product_id = rc.product_id
ORDER BY cs_rate_pct DESC;

Result (top 7 of 30 rows)

product_name total_sold complaints returns total_cs cs_rate_pct
SK hynix Platinum P41 2TB Silver 183 22 9 31 16.90
Crucial T700 2TB Silver 173 17 9 26 15.00
Intel Core Ultra 7 265K White 405 33 18 51 12.60
Logitech G715 240 21 9 30 12.50
Kingston FURY Beast DDR4 16GB Silver 170 15 6 21 12.40
Kingston FURY Renegade DDR5 32GB 7200... 131 14 2 16 12.20
AMD Ryzen 9 9900X 248 21 9 30 12.10

18. Daily operations dashboard

Create a dashboard that shows your operational status at a glance on a specific date (2024-12-15). Includes number of same-day orders, sales, new subscribers, completed deliveries, and number of outstanding CS cases.

Hint 1: - Calculate each indicator as a scalar subquery or CTE - Combine single rows with CROSS JOIN - It is more useful to include changes compared to the previous day.

Answer
WITH target_day AS (SELECT '2024-12-15' AS d),
day_orders AS (
    SELECT
        COUNT(*) AS order_count,
        ROUND(SUM(total_amount)) AS revenue
    FROM orders, target_day
    WHERE DATE(ordered_at) = d
      AND status NOT IN ('cancelled', 'returned', 'return_requested')
),
prev_orders AS (
    SELECT
        COUNT(*) AS order_count,
        ROUND(SUM(total_amount)) AS revenue
    FROM orders, target_day
    WHERE DATE(ordered_at) = DATE(d, '-1 day')
      AND status NOT IN ('cancelled', 'returned', 'return_requested')
),
new_customers AS (
    SELECT COUNT(*) AS cnt
    FROM customers, target_day
    WHERE DATE(created_at) = d
),
deliveries AS (
    SELECT COUNT(*) AS cnt
    FROM shipping, target_day
    WHERE DATE(delivered_at) = d
),
open_cs AS (
    SELECT COUNT(*) AS cnt
    FROM complaints
    WHERE status = 'open'
)
SELECT
    (SELECT d FROM target_day) AS report_date,
    do.order_count AS today_orders,
    do.revenue AS today_revenue,
    po.order_count AS yesterday_orders,
    po.revenue AS yesterday_revenue,
    ROUND(100.0 * (do.revenue - po.revenue) / NULLIF(po.revenue, 0), 1) AS revenue_change_pct,
    nc.cnt AS new_signups,
    dl.cnt AS deliveries_completed,
    oc.cnt AS open_cs_tickets
FROM day_orders AS do
CROSS JOIN prev_orders AS po
CROSS JOIN new_customers AS nc
CROSS JOIN deliveries AS dl
CROSS JOIN open_cs AS oc;

Result (1 rows)

report_date today_orders today_revenue yesterday_orders yesterday_revenue revenue_change_pct new_signups deliveries_completed open_cs_tickets
2024-12-15 17 14,671,908.00 16 8,477,723.00 73.10 0 15 197

19. Comprehensive evaluation of suppliers

Evaluate each supplier’s overall performance. Consolidate number of units supplied, total sales, return rate, average review rating, and inventory availability into one report.

Hint 1: - suppliersproducts → JOIN various tables - Return rate: Number of returns/number of sales of the supplier’s products - Inventory adequacy: Proportion of products with excess inventory (more than 180 days) or shortage (7 days or less)

Answer
WITH supplier_products AS (
    SELECT
        s.id AS supplier_id,
        s.company_name,
        COUNT(DISTINCT p.id) AS product_count,
        SUM(p.stock_qty) AS total_stock
    FROM suppliers AS s
    INNER JOIN products AS p ON s.id = p.supplier_id
    WHERE p.is_active = 1
    GROUP BY s.id, s.company_name
),
supplier_sales AS (
    SELECT
        p.supplier_id,
        SUM(oi.quantity) AS total_sold,
        ROUND(SUM(oi.quantity * oi.unit_price)) AS total_revenue
    FROM order_items AS oi
    INNER JOIN orders AS o ON oi.order_id = o.id
    INNER JOIN products AS p ON oi.product_id = p.id
    WHERE o.ordered_at >= '2024-01-01' AND o.ordered_at < '2025-01-01'
      AND o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY p.supplier_id
),
supplier_returns AS (
    SELECT
        p.supplier_id,
        COUNT(DISTINCT ret.id) AS return_count
    FROM returns AS ret
    INNER JOIN order_items AS oi ON ret.order_id = oi.order_id
    INNER JOIN products AS p ON oi.product_id = p.id
    WHERE ret.requested_at >= '2024-01-01' AND ret.requested_at < '2025-01-01'
    GROUP BY p.supplier_id
),
supplier_reviews AS (
    SELECT
        p.supplier_id,
        ROUND(AVG(r.rating), 2) AS avg_rating,
        COUNT(*) AS review_count
    FROM reviews AS r
    INNER JOIN products AS p ON r.product_id = p.id
    GROUP BY p.supplier_id
)
SELECT
    sp.company_name,
    sp.product_count,
    COALESCE(ss.total_revenue, 0) AS revenue_2024,
    COALESCE(ss.total_sold, 0) AS units_sold,
    ROUND(100.0 * COALESCE(sr.return_count, 0) / NULLIF(ss.total_sold, 0), 1) AS return_rate_pct,
    COALESCE(srv.avg_rating, 0) AS avg_rating,
    sp.total_stock
FROM supplier_products AS sp
LEFT JOIN supplier_sales AS ss ON sp.supplier_id = ss.supplier_id
LEFT JOIN supplier_returns AS sr ON sp.supplier_id = sr.supplier_id
LEFT JOIN supplier_reviews AS srv ON sp.supplier_id = srv.supplier_id
ORDER BY revenue_2024 DESC;

Result (top 7 of 41 rows)

company_name product_count revenue_2024 units_sold return_rate_pct avg_rating total_stock
ASUS Corp. 21 794,354,100.00 828 4.00 3.88 5828
Razer Corp. 7 482,608,700.00 445 3.60 3.89 1742
LG Official Distribution 11 434,068,900.00 389 3.90 3.91 2667
Samsung Official Distribution 21 427,788,200.00 1012 3.50 3.90 6174
MSI Corp. 12 372,284,800.00 632 3.00 3.97 4070
ASRock Corp. 9 238,530,100.00 537 3.20 3.78 3084
SteelSeries Corp. 7 209,711,600.00 983 3.70 3.87 1626

20. Monthly Management Comprehensive Report (December 2024)

Create a comprehensive management report for December 2024. Integrates key KPIs from four areas: sales, customers, inventory, and CS into one query.

KPI Area Metrics
Sales Monthly revenue, MoM ratio, YoY same-month ratio
Customers Active customer count, new signups, repeat purchase rate
Inventory Out-of-stock product count, inventory value, low-stock alert count
CS Unresolved cases, avg resolution time, resolution rate

Hint 1: - Write each area as a separate CTE and then CROSS JOIN - Calculate comparative period sales using subquery for previous month ratio/previous year same month ratio - Repurchase rate: The percentage of customers who ordered previously in that month

Answer
WITH sales_kpi AS (
    SELECT
        ROUND(SUM(CASE WHEN ordered_at LIKE '2024-12%' THEN total_amount ELSE 0 END)) AS dec_revenue,
        ROUND(SUM(CASE WHEN ordered_at LIKE '2024-11%' THEN total_amount ELSE 0 END)) AS nov_revenue,
        ROUND(SUM(CASE WHEN ordered_at LIKE '2023-12%' THEN total_amount ELSE 0 END)) AS dec_2023_revenue
    FROM orders
    WHERE status NOT IN ('cancelled', 'returned', 'return_requested')
      AND (ordered_at LIKE '2024-12%' OR ordered_at LIKE '2024-11%' OR ordered_at LIKE '2023-12%')
),
customer_kpi AS (
    SELECT
        COUNT(DISTINCT CASE WHEN o.ordered_at LIKE '2024-12%' THEN o.customer_id END) AS active_customers,
        (SELECT COUNT(*) FROM customers WHERE created_at LIKE '2024-12%') AS new_signups,
        ROUND(100.0 * COUNT(DISTINCT CASE
            WHEN o.ordered_at LIKE '2024-12%'
            AND o.customer_id IN (
                SELECT customer_id FROM orders
                WHERE ordered_at < '2024-12-01'
                  AND status NOT IN ('cancelled', 'returned', 'return_requested')
            )
            THEN o.customer_id
        END) / NULLIF(COUNT(DISTINCT CASE WHEN o.ordered_at LIKE '2024-12%' THEN o.customer_id END), 0), 1)
            AS repeat_rate_pct
    FROM orders AS o
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
),
inventory_kpi AS (
    SELECT
        SUM(CASE WHEN stock_qty = 0 THEN 1 ELSE 0 END) AS out_of_stock_count,
        ROUND(SUM(stock_qty * cost_price)) AS total_stock_value,
        SUM(CASE WHEN stock_qty > 0 AND stock_qty <= 10 THEN 1 ELSE 0 END) AS low_stock_warning
    FROM products
    WHERE is_active = 1
),
cs_kpi AS (
    SELECT
        SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) AS open_tickets,
        ROUND(AVG(CASE
            WHEN resolved_at IS NOT NULL AND created_at LIKE '2024-12%'
            THEN JULIANDAY(resolved_at) - JULIANDAY(created_at)
        END), 2) AS avg_resolution_days,
        ROUND(100.0 * SUM(CASE
            WHEN created_at LIKE '2024-12%' AND status IN ('resolved', 'closed') THEN 1 ELSE 0 END)
            / NULLIF(SUM(CASE WHEN created_at LIKE '2024-12%' THEN 1 ELSE 0 END), 0), 1)
            AS resolution_rate_pct
    FROM complaints
)
SELECT
    '2024-12' AS report_month,
    -- 매출
    sk.dec_revenue,
    ROUND(100.0 * (sk.dec_revenue - sk.nov_revenue) / NULLIF(sk.nov_revenue, 0), 1) AS mom_growth_pct,
    ROUND(100.0 * (sk.dec_revenue - sk.dec_2023_revenue) / NULLIF(sk.dec_2023_revenue, 0), 1) AS yoy_growth_pct,
    -- 고객
    ck.active_customers,
    ck.new_signups,
    ck.repeat_rate_pct,
    -- 재고
    ik.out_of_stock_count,
    ik.total_stock_value,
    ik.low_stock_warning,
    -- CS
    csk.open_tickets,
    csk.avg_resolution_days,
    csk.resolution_rate_pct
FROM sales_kpi AS sk
CROSS JOIN customer_kpi AS ck
CROSS JOIN inventory_kpi AS ik
CROSS JOIN cs_kpi AS csk;

Result (1 rows)

report_month dec_revenue mom_growth_pct yoy_growth_pct active_customers new_signups repeat_rate_pct out_of_stock_count total_stock_value low_stock_warning open_tickets avg_resolution_days resolution_rate_pct
2024-12 417,148,762.00 -23.20 14.90 379 59 90.20 1 30,030,260,700.00 2 197 1.39 91.70