Ebook · Hoofdstuk 10 van 10

Case Study: Volledige Implementatie

Retailbedrijf RetailCo van 0 naar productie. Alle hoofdstukken in één doorlopend, realistisch voorbeeld.

RetailCo: een fictieve klant

30 min leestijd Alle niveaus

Genoeg theorie. In dit slothoofdstuk lopen we van begin tot eind door de bouw van een echt datawarehouse. RetailCo is fictief, maar de keuzes en problemen zijn ontleend aan echte projecten. We doen het in chronologische volgorde — niet zoals het mooi opgeschreven kan worden, maar zoals het op kantoor werkt.

1. Bedrijfscontext

RetailCo is een mid-market modeketen: 87 winkels in de Benelux, één e-commerce platform, ~120 medewerkers op het hoofdkantoor. Jaaromzet ~€85M. Bestaande BI: Excel-rapporten uit het ERP, met handmatige consolidatie naar e-commerce. Frustratie: cijfers kloppen niet over teams heen, en CEO ziet pas op woensdag de cijfers van vorige week.

Sponsor: CFO. Doel: dagelijkse, geconsolideerde KPI's voor sales, voorraad en marketing — beschikbaar om 08:00 op een Power BI dashboard.

2. Stakeholders en analytical questions

Na vier 1-op-1's komt deze prioriteitslijst:

3. Niet-functionele requirements

4. Architectuurkeuze

Met "Power BI eindgebruikers, beperkt budget, mid-market schaal, T-1 freshness" wordt de keuze:

5. Bronsystemen

BronTypeConnectorRefreshVolume / dag
Microsoft Dynamics ERPSQL Server on-premAirbyte (CDC)Hourly~1M rijen
Shopify (e-commerce)SaaS APIFivetranHourly~50k rijen
Salesforce CRMSaaS APIFivetranDaily~5k rijen
Klaviyo (marketing)SaaS APIFivetranDaily~10k rijen
POS systeemDaily CSV via SFTPCustom PythonDaily 02:00~200k rijen

6. Data model — bus matrix

Proces ↓ / Dimensie → Datum Klant Product Winkel Kanaal Promotie
Verkoop (POS + e-com)
Voorraad
Marketing
Inkoop

7. Fysiek model — gold layer

-- DIMENSIES
CREATE TABLE dim_date (
  date_key      INT PRIMARY KEY,
  full_date     DATE,
  year          INT,
  quarter       INT,
  month         INT,
  month_name    VARCHAR(20),
  week_of_year  INT,
  day_of_week   INT,
  weekday_name  VARCHAR(20),
  is_weekend    BOOLEAN,
  is_holiday    BOOLEAN
);

CREATE TABLE dim_customer (
  customer_key  INT IDENTITY PRIMARY KEY,
  pseudonym_id  CHAR(64),                  -- referentie naar pii_customer
  customer_id   VARCHAR(50),
  segment       VARCHAR(50),
  acq_channel   VARCHAR(50),
  country       VARCHAR(50),
  signup_date   DATE,
  valid_from    DATE,
  valid_to      DATE,
  is_current    BOOLEAN,
  hash_diff     CHAR(64)
);

CREATE TABLE dim_product (
  product_key   INT IDENTITY PRIMARY KEY,
  product_id    VARCHAR(50),
  sku           VARCHAR(50),
  name          VARCHAR(200),
  category      VARCHAR(100),
  subcategory   VARCHAR(100),
  brand         VARCHAR(100),
  unit_cost_eur DECIMAL(10,2),
  unit_price_eur DECIMAL(10,2),
  valid_from    DATE,
  valid_to      DATE,
  is_current    BOOLEAN,
  hash_diff     CHAR(64)
);

CREATE TABLE dim_store (
  store_key     INT IDENTITY PRIMARY KEY,
  store_id      VARCHAR(20),
  store_name    VARCHAR(100),
  region        VARCHAR(50),
  country       VARCHAR(50),
  m2            INT,
  opened_date   DATE,
  is_active     BOOLEAN
);

CREATE TABLE dim_channel (
  channel_key   INT PRIMARY KEY,
  channel_name  VARCHAR(50)         -- 'POS', 'Web', 'App', 'Marketplace'
);

-- FACTS
CREATE TABLE fact_sales (
  sales_key     BIGINT IDENTITY,
  date_key      INT,
  customer_key  INT,
  product_key   INT,
  store_key     INT,
  channel_key   INT,
  order_id      VARCHAR(50),        -- degenerate dim
  quantity      INT,
  unit_price    DECIMAL(10,2),
  unit_cost     DECIMAL(10,2),
  discount      DECIMAL(10,2),
  net_amount    DECIMAL(12,2),
  gross_margin  DECIMAL(12,2),
  load_ts       TIMESTAMP
)
CLUSTER BY (date_key);

CREATE TABLE fact_inventory_daily (
  date_key      INT,
  product_key   INT,
  store_key     INT,
  units_on_hand INT,
  units_received INT,
  units_sold    INT,
  inventory_value_eur DECIMAL(12,2),
  PRIMARY KEY (date_key, product_key, store_key)
);

8. dbt project structuur

retailco_dwh/
├── dbt_project.yml
├── models/
│   ├── staging/
│   │   ├── erp/
│   │   │   ├── stg_erp__sales_lines.sql
│   │   │   └── stg_erp__inventory.sql
│   │   ├── shopify/
│   │   │   ├── stg_shopify__orders.sql
│   │   │   └── stg_shopify__customers.sql
│   │   └── salesforce/
│   │       └── stg_salesforce__accounts.sql
│   ├── intermediate/
│   │   ├── int_sales_unioned.sql        -- POS + e-com
│   │   └── int_customers_mastered.sql   -- MDM matching
│   └── marts/
│       ├── dim_date.sql
│       ├── dim_customer.sql              -- SCD Type 2
│       ├── dim_product.sql
│       ├── dim_store.sql
│       ├── dim_channel.sql
│       ├── fact_sales.sql
│       └── fact_inventory_daily.sql
├── tests/
│   ├── assert_sales_total_matches_lines.sql
│   └── assert_no_negative_quantities.sql
└── macros/
    └── generate_surrogate_key.sql

9. Voorbeeldmodel — fact_sales

-- models/marts/fact_sales.sql
{{ config(
    materialized='incremental',
    incremental_strategy='merge',
    unique_key='sales_key',
    cluster_by=['date_key']
) }}

WITH s AS (
    SELECT *
    FROM   {{ ref('int_sales_unioned') }}
    {% if is_incremental() %}
      WHERE load_ts > (SELECT MAX(load_ts) FROM {{ this }})
    {% endif %}
)
SELECT
    {{ dbt_utils.generate_surrogate_key(['s.order_id', 's.line_no']) }} AS sales_key,
    TO_NUMBER(TO_CHAR(s.order_date, 'YYYYMMDD'))     AS date_key,
    c.customer_key,
    p.product_key,
    st.store_key,
    ch.channel_key,
    s.order_id,
    s.quantity,
    s.unit_price,
    p.unit_cost_eur                                  AS unit_cost,
    s.discount,
    s.quantity * (s.unit_price - s.discount)         AS net_amount,
    s.quantity * (s.unit_price - p.unit_cost_eur)    AS gross_margin,
    CURRENT_TIMESTAMP                                AS load_ts
FROM   s
LEFT JOIN {{ ref('dim_customer') }} c
       ON s.customer_id = c.customer_id AND c.is_current
LEFT JOIN {{ ref('dim_product') }} p
       ON s.product_id  = p.product_id  AND p.is_current
LEFT JOIN {{ ref('dim_store') }} st
       ON s.store_id    = st.store_id
LEFT JOIN {{ ref('dim_channel') }} ch
       ON s.channel     = ch.channel_name

10. Quality tests

version: 2
models:
  - name: fact_sales
    columns:
      - name: sales_key
        tests: [unique, not_null]
      - name: date_key
        tests:
          - not_null
          - relationships: { to: ref('dim_date'), field: date_key }
      - name: customer_key
        tests:
          - relationships: { to: ref('dim_customer'), field: customer_key }
      - name: net_amount
        tests:
          - dbt_utils.expression_is_true: { expression: ">= 0" }
      - name: gross_margin
        tests:
          - dbt_utils.expression_is_true: { expression: "<= net_amount" }

  - name: dim_customer
    columns:
      - name: customer_key
        tests: [unique, not_null]
      - name: customer_id
        tests:
          - dbt_utils.unique_combination_of_columns:
              combination_of_columns: [customer_id, valid_from]

sources:
  - name: erp
    schema: raw_erp
    freshness: { warn_after: { count: 6, period: hour }, error_after: { count: 12, period: hour } }
    tables:
      - name: sales_lines
        loaded_at_field: _loaded_at

11. Pipeline-schema

-- dbt Cloud Job: 'daily_full_build'
-- Schedule: 0 4 * * *  (04:00 UTC)
--
-- Stappen:
--   1. dbt source freshness  → fail-fast als bron-data te oud is
--   2. dbt run --select staging       (alle stg_ modellen)
--   3. dbt run --select intermediate
--   4. dbt run --select marts
--   5. dbt test                       (alle quality tests)
--   6. dbt docs generate              (auto-deploy)
--   7. Slack notify on success/failure

12. Voorbeeld dashboard query

-- Dagelijkse sales per winkel met YoY-vergelijking
WITH this_year AS (
    SELECT s.store_key, st.store_name, st.region,
           SUM(f.net_amount) AS revenue_ytd,
           SUM(f.gross_margin) AS margin_ytd
    FROM   fact_sales f
    JOIN   dim_store st ON st.store_key = f.store_key
    WHERE  f.date_key BETWEEN 20260101 AND TO_NUMBER(TO_CHAR(CURRENT_DATE, 'YYYYMMDD'))
    GROUP  BY 1, 2, 3
),
last_year AS (
    SELECT s.store_key,
           SUM(f.net_amount) AS revenue_ly
    FROM   fact_sales f
    WHERE  f.date_key BETWEEN 20250101 AND TO_NUMBER(TO_CHAR(DATEADD(YEAR, -1, CURRENT_DATE), 'YYYYMMDD'))
    GROUP  BY 1
)
SELECT t.store_name, t.region, t.revenue_ytd, t.margin_ytd,
       l.revenue_ly,
       (t.revenue_ytd - l.revenue_ly) / NULLIF(l.revenue_ly, 0) AS yoy_growth
FROM   this_year t
LEFT JOIN last_year l ON l.store_key = t.store_key
ORDER  BY t.revenue_ytd DESC;

13. Wat ging mis (en wat we leerden)

Geen project zonder hobbels. Drie incidenten uit RetailCo's eerste jaar:

14. Resultaten na 9 maanden

15. Wat we de volgende keer anders zouden doen

Slotwoord

Een datawarehouse bouwen is geen technisch project — het is een project waarin techniek het slotsom is van duidelijke afspraken, gedeelde definities en operationele discipline. Elk hoofdstuk van dit ebook raakt een hoek van die discipline. De magie zit niet in een specifieke tool of patroon, maar in de combinatie: weten wat je business vraagt, kiezen wat past, uitvoeren met aandacht voor kwaliteit, en blijven onderhouden.

Veel succes met je eigen warehouse — en denk eraan: de eerste versie is altijd de slechtste. Bouw, leer, verbeter.

Verder met DataPartner365

Dit ebook is geschreven om vrij beschikbaar te zijn. Heb je hulp nodig bij een echt project — van architectuurkeuze tot implementatie of doorontwikkeling? Neem contact op voor een vrijblijvend gesprek.

Contact opnemen · Lees de blog · Andere leerpaden