Systems architecture · End-to-end data platform
From four source databases to one governed BI layer
Both TMS systems, Sage Intacct, and the maintenance system feed the warehouse through SnapLogic pipelines that Prefect orchestrates; Qlik Sense sits on top of the conformed model and carries intelligence out to the organization. The ETL demo below walks through one of those pipelines in detail.
Synthetic demonstration dataSolid arrows carry data left to right; dashed lines are Prefect's control plane.
- Source database
- SnapLogic pipelines
- Data warehouse
- Qlik Sense
- Prefect orchestration
- Data flow
- Control / scheduling
Layer detail
The same stack, expanded: what each layer contributes and how responsibility is divided between the pipeline tool, the orchestrator, the warehouse, and the BI layer.
Source databases
TMS1 — LegacyRoadlegacy
On-prem SQL Server · dispatch & billing (sunsetting)
TMS2 — NexHaul Cloud
Cloud TMS · system of record for operations
Sage Intacct
Financial ERP · GL, AP/AR, invoicing
Maintenance System
Shop work orders, parts, unit downtime
Integration & orchestration
SnapLogic — data pipelines
Extract from each source · conform field names and data types to the warehouse model · merge overlapping TMS1/TMS2 entities · land to staging
Prefect — orchestration
Schedules and dependencies across every pipeline · retries and failure alerting · data-quality gates before promotion · run lineage and audit logs
Storage & modeling
Data Warehouse
Staging (source-shaped) → conformed dimensional model (FactLoad, FactInvoice, shared dimensions) → subject-area marts. One auditable home for every load, invoice, and work order.
Business intelligence
Qlik Sense
Governed apps on the warehouse marts — associative self-service analysis, section access for row-level security, reload tasks triggered after Prefect marks loads green.
Dissemination to the organization
Executive dashboards
Margin, utilization, service KPIs
Operations desk
Late-load & exception alerts
Finance & AR
Invoice aging, cost reconciliation
Org-wide distribution
Scheduled reports & embedded views
Reading the flow: SnapLogic owns how data moves and reshapes; Prefect owns when it moves and whether it's trustworthy; the warehouse owns the single conformed model; Qlik Sense never reads a source system directly — every dashboard traces back through the governed path.
Technical demo · ETL pipeline construction
Migrating a legacy TMS without breaking the warehouse
Two transportation management systems run in parallel during a migration: the legacy on-prem system (TMS1) and its cloud replacement (TMS2). The same entities live in both with different names, formats, and types. Pick a table to compare the two models, see the field mapping, and read the Prefect flow that unifies them nightly.
- SQL Server (SSMS)Extract dbo.* via pyodbc
- PrefectOrchestration, retries, logging
- SnapLogicRename fields · cast types
- MergeUnion + dedupe on natural key
- WarehouseStaged, transactional load
Migration table
Loads dispatched and delivered. TMS1 stores dates and money as text, city and region in one column, and status as a numeric code.
LegacyRoad — on-prem SQL Server
dbo.Shipments · top 10 rows
| ShipmentIDint | Ship_Dtvarchar(10) | Del_Dtvarchar(10) | Origvarchar(60) | Destvarchar(60) | Cust_Cdvarchar(8) | Equip_Cdchar(2) | XBorderchar(1) | Ld_Milesvarchar(8) | Tot_Revvarchar(12) | Stssmallint |
|---|---|---|---|---|---|---|---|---|---|---|
| 10241 | 06/29/2026 | 07/01/2026 | TORONTO, ON | CHICAGO, IL | NGRG01 | DV | Y | 848 | $2,481.60 | 4 |
| 10242 | 06/29/2026 | 06/30/2026 | CHICAGO, IL | WINNIPEG, MB | PFCO02 | RF | Y | 901 | $3,612.75 | 4 |
| 10243 | 06/30/2026 | 07/02/2026 | DALLAS, TX | CHICAGO, IL | NGRG01 | DV | N | 972 | $2,355.10 | 3 |
| 10244 | 06/30/2026 | 07/01/2026 | LAREDO, TX | MONTERREY, MX | TBMT05 | FB | Y | 244 | $1,163.20 | 4 |
| 10245 | 07/01/2026 | 07/01/2026 | TORONTO, ON | MONTREAL, QC | LKBV03 | DV | N | 352 | $989.45 | 3 |
| 10246 | 07/01/2026 | 07/03/2026 | CALGARY, AB | DENVER, CO | GPAS08 | RF | Y | 1104 | $4,020.00 | 2 |
| 10247 | 07/02/2026 | 07/03/2026 | TORONTO, ON | DETROIT, MI | DMAC04 | DV | Y | 236 | $1,041.30 | 3 |
| 10248 | 07/02/2026 | 07/04/2026 | DALLAS, TX | ATLANTA, GA | MWBP06 | FB | N | 791 | $2,196.85 | 2 |
| 10249 | 07/03/2026 | 07/05/2026 | MONTREAL, QC | TORONTO, ON | CSPP07 | DV | N | 349 | $918.20 | 1 |
| 10250 | 07/03/2026 | 07/06/2026 | MONTERREY, MX | LAREDO, TX | DMAC04 | DV | Y | 251 | $1,205.00 | 1 |
NexHaul Cloud — target data model
tms2.shipments · top 10 rows
| shipment_idbigint | shipment_numbervarchar(12) | pickup_datedate | delivery_datedate | origin_cityvarchar(40) | origin_regionchar(2) | dest_cityvarchar(40) | dest_regionchar(2) | client_codevarchar(8) | equipment_typevarchar(10) | cross_borderboolean | loaded_milesint | total_revenuedecimal(12,2) | statusvarchar(12) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20411 | SH-20411 | 2026-07-06 | 2026-07-08 | Chicago | IL | Toronto | ON | NGRG01 | dry_van | true | 835 | 2464.2 | delivered |
| 20412 | SH-20412 | 2026-07-06 | 2026-07-07 | Toronto | ON | Montreal | QC | LKBV03 | dry_van | false | 346 | 972.4 | delivered |
| 20413 | SH-20413 | 2026-07-06 | 2026-07-08 | Chicago | IL | Winnipeg | MB | PFCO02 | reefer | true | 887 | 3548.1 | in_transit |
| 20414 | SH-20414 | 2026-07-07 | 2026-07-08 | Laredo | TX | Monterrey | MX | TBMT05 | flatbed | true | 249 | 1187.5 | in_transit |
| 20415 | SH-20415 | 2026-07-07 | 2026-07-09 | Denver | CO | Calgary | AB | GPAS08 | reefer | true | 1092 | 3961.75 | in_transit |
| 20416 | SH-20416 | 2026-07-07 | 2026-07-09 | Atlanta | GA | Chicago | IL | MWBP06 | flatbed | false | 724 | 2011.3 | planned |
| 20417 | SH-20417 | 2026-07-08 | 2026-07-09 | Toronto | ON | Detroit | MI | DMAC04 | dry_van | true | 233 | 1028.6 | planned |
| 20418 | SH-20418 | 2026-07-08 | 2026-07-10 | Dallas | TX | Laredo | TX | GPAS08 | dry_van | false | 441 | 1112.9 | planned |
| 20419 | SH-20419 | 2026-07-08 | 2026-07-10 | Chicago | IL | Dallas | TX | NGRG01 | dry_van | false | 968 | 2340.55 | planned |
| 20420 | SH-20420 | 2026-07-09 | 2026-07-11 | Montreal | QC | Toronto | ON | CSPP07 | dry_van | false | 351 | 931 | planned |
Field mapping — applied inside the SnapLogic pipeline
Dedupe key after conforming: shipment_number (TMS2 wins collisions — it is the system of record for migrated entities)
| TMS1 field | TMS1 type | TMS2 field | TMS2 type | Transform |
|---|---|---|---|---|
| ShipmentID | int | shipment_number | varchar(12) | Prefix legacy key → 'LR-' + id (keeps both systems unique) |
| Ship_Dt | varchar(10) | pickup_date | date | Parse MM/DD/YYYY → ISO 8601 date |
| Del_Dt | varchar(10) | delivery_date | date | Parse MM/DD/YYYY → ISO 8601 date |
| Orig | varchar(60) | origin_city / origin_region | varchar(40) / char(2) | Split on ',' · title-case city · trim region |
| Dest | varchar(60) | dest_city / dest_region | varchar(40) / char(2) | Split on ',' · title-case city · trim region |
| Cust_Cd | varchar(8) | client_code | varchar(8) | Rename · uppercase · trim |
| Equip_Cd | char(2) | equipment_type | varchar(10) | Code map: DV→dry_van · RF→reefer · FB→flatbed |
| XBorder | char(1) | cross_border | boolean | Y/N → true/false |
| Ld_Miles | varchar(8) | loaded_miles | int | Cast text → int |
| Tot_Rev | varchar(12) | total_revenue | decimal(12,2) | Strip '$' and ',' → decimal(12,2) |
| Sts | smallint | status | varchar(12) | Code map: 1→planned · 2→in_transit · 3→delivered · 4→invoiced |
Orchestration — Prefect flow for this table
The flow below updates with the selected table. Extraction pulls the raw legacy rows untouched; the SnapLogic triggered task owns every rename and cast (validated against an explicit schema contract); the merge dedupes on the natural key with TMS2 as system of record; the load stages and swaps inside one transaction.
flows/migrate_shipments.py
"""
Migration flow — Shipments: LegacyRoad (TMS1) -> NexHaul (TMS2) -> warehouse.
Extract dbo.Shipments from the on-prem SQL Server behind SSMS, conform it
to the TMS2 data model through a SnapLogic pipeline (field renames + type
casts live there, version-controlled with the pipeline), merge with native
TMS2 records, and load the unified table to the warehouse.
"""
import io
import os
import pandas as pd
import pyodbc
import requests
from prefect import flow, task, get_run_logger
from prefect.blocks.system import Secret
SOURCE_TABLE = "dbo.Shipments" # TMS1, SQL Server (SSMS)
TMS2_TABLE = "tms2.shipments" # TMS2, already conformed
WAREHOUSE_TABLE = "dw.fact_shipment" # load target
NATURAL_KEY = "shipment_number" # dedupe key after conforming
SNAPLOGIC_TASK_URL = (
"https://elastic.snaplogic.com/api/1/rest/slsched/feed/FreightIntel"
"/migration/shipments_conform_task"
)
# Contract with the SnapLogic pipeline: exact columns + types it must emit.
EXPECTED_SCHEMA = {
"shipment_id": "bigint",
"shipment_number": "varchar(12)",
"pickup_date": "date",
"delivery_date": "date",
"origin_city": "varchar(40)",
"origin_region": "char(2)",
"dest_city": "varchar(40)",
"dest_region": "char(2)",
"client_code": "varchar(8)",
"equipment_type": "varchar(10)",
"cross_border": "boolean",
"loaded_miles": "int",
"total_revenue": "decimal(12,2)",
"status": "varchar(12)",
}
def sqlserver_conn(dsn_secret: str) -> pyodbc.Connection:
"""Connection via a DSN stored in a Prefect Secret block (no creds in code)."""
return pyodbc.connect(Secret.load(dsn_secret).get(), timeout=30)
@task(retries=3, retry_delay_seconds=60)
def extract_tms1() -> pd.DataFrame:
"""Pull the legacy table exactly as stored — no transforms at extract time."""
logger = get_run_logger()
with sqlserver_conn("tms1-ssms-dsn") as conn:
df = pd.read_sql(f"SELECT * FROM {SOURCE_TABLE};", conn)
logger.info("TMS1 extract: %s rows from %s", len(df), SOURCE_TABLE)
return df
@task(retries=3, retry_delay_seconds=120)
def conform_via_snaplogic(tms1_df: pd.DataFrame) -> pd.DataFrame:
"""
POST the raw TMS1 rows to the SnapLogic triggered task that owns the
field-name and data-type mapping, and read back TMS2-shaped records.
"""
logger = get_run_logger()
response = requests.post(
SNAPLOGIC_TASK_URL,
params={"bearer_token": Secret.load("snaplogic-token").get()},
json=tms1_df.to_dict(orient="records"),
timeout=600,
)
response.raise_for_status()
conformed = pd.DataFrame(response.json())
missing = set(EXPECTED_SCHEMA) - set(conformed.columns)
if missing:
raise ValueError(f"SnapLogic output missing columns: {sorted(missing)}")
logger.info("SnapLogic conformed %s rows to the TMS2 model", len(conformed))
return conformed
@task(retries=3, retry_delay_seconds=60)
def extract_tms2() -> pd.DataFrame:
"""Native TMS2 records — already in the target model."""
logger = get_run_logger()
with sqlserver_conn("tms2-replica-dsn") as conn:
df = pd.read_sql(f"SELECT * FROM {TMS2_TABLE};", conn)
logger.info("TMS2 extract: %s rows from %s", len(df), TMS2_TABLE)
return df
@task
def merge_systems(conformed_tms1: pd.DataFrame, tms2: pd.DataFrame) -> pd.DataFrame:
"""
Union both systems on the shared model, then dedupe on the natural key.
TMS2 wins collisions: it is the system of record for migrated entities.
"""
logger = get_run_logger()
merged = pd.concat(
[conformed_tms1.assign(source_system="TMS1"), tms2.assign(source_system="TMS2")],
ignore_index=True,
)
before = len(merged)
merged = merged.sort_values("source_system").drop_duplicates(NATURAL_KEY, keep="last")
logger.info("Merged %s rows -> %s after dedupe on %s", before, len(merged), NATURAL_KEY)
return merged
@task(retries=2, retry_delay_seconds=300)
def load_warehouse(df: pd.DataFrame) -> int:
"""
Bulk-load to a staging table, then swap into the target inside one
transaction so downstream reports never see a half-loaded table.
"""
logger = get_run_logger()
staging = f"{WAREHOUSE_TABLE}_stg"
with sqlserver_conn("warehouse-dsn") as conn:
cursor = conn.cursor()
cursor.fast_executemany = True
cursor.execute(f"TRUNCATE TABLE {staging};")
placeholders = ", ".join("?" for _ in df.columns)
cursor.executemany(
f"INSERT INTO {staging} ({', '.join(df.columns)}) VALUES ({placeholders})",
df.itertuples(index=False, name=None),
)
cursor.execute(
f"BEGIN TRAN; TRUNCATE TABLE {WAREHOUSE_TABLE}; "
f"INSERT INTO {WAREHOUSE_TABLE} SELECT * FROM {staging}; COMMIT;"
)
conn.commit()
logger.info("Loaded %s rows into %s", len(df), WAREHOUSE_TABLE)
return len(df)
@flow(name="migrate-shipments", log_prints=True)
def migrate_shipments() -> None:
tms1_raw = extract_tms1()
conformed = conform_via_snaplogic(tms1_raw)
tms2_native = extract_tms2()
unified = merge_systems(conformed, tms2_native)
load_warehouse(unified)
if __name__ == "__main__":
# Local run; in production this deploys on a schedule via prefect.yaml.
migrate_shipments()