251 lines
10 KiB
Python
251 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import random
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
FIRST_NAMES = ["Sofie", "Lotte", "Emma", "Noor", "Julie", "Thomas", "Bram", "Wout", "Niels", "Pieter", "Anke", "Sara", "Eva", "Tom", "Jeroen"]
|
|
LAST_NAMES = ["Peeters", "Janssens", "Maes", "Willems", "Claes", "Vermeulen", "Jacobs", "Mertens", "Goossens", "Wouters", "De Smet", "Vandamme"]
|
|
CITIES = [("2440", "Geel"), ("2300", "Turnhout"), ("2200", "Herentals"), ("2400", "Mol"), ("2260", "Westerlo"), ("3980", "Tessenderlo-Ham")]
|
|
MAKES_MODELS = [("Adria", "Matrix"), ("Dethleffs", "Trend"), ("Carado", "T-Series"), ("Hymer", "Exsis"), ("Bürstner", "Lyseo"), ("Sunlight", "Cliff")]
|
|
|
|
|
|
def write_csv(path: Path, rows: list[dict]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
|
|
|
|
def iso(dt: datetime) -> str:
|
|
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def build(anchor: date, seed: int, out: Path) -> None:
|
|
rnd = random.Random(seed)
|
|
customers = []
|
|
used_emails = set()
|
|
for i in range(1, 181):
|
|
first = FIRST_NAMES[(i * 3) % len(FIRST_NAMES)]
|
|
last = LAST_NAMES[(i * 5) % len(LAST_NAMES)]
|
|
postal, city = CITIES[i % len(CITIES)]
|
|
email = f"{first}.{last}.{i}@example.test".lower().replace(" ", "")
|
|
used_emails.add(email)
|
|
customers.append({
|
|
"public_ref": f"CUS-{i:04d}",
|
|
"first_name": first,
|
|
"last_name": last,
|
|
"email": email,
|
|
"phone": f"+32 4{70 + (i % 20):02d} {100000 + i:06d}",
|
|
"postal_code": postal,
|
|
"city": city,
|
|
"merged_into": "",
|
|
})
|
|
|
|
# Fixed duplicate: CUS-0178 mirrors CUS-0012 with a shortened first name.
|
|
src = customers[11]
|
|
dup = customers[177]
|
|
dup.update({
|
|
"first_name": src["first_name"][0] + ".",
|
|
"last_name": src["last_name"],
|
|
"email": src["email"],
|
|
"phone": src["phone"],
|
|
"postal_code": src["postal_code"],
|
|
"city": src["city"],
|
|
})
|
|
# Two additional duplicate pairs.
|
|
for a, b in [(31, 164), (68, 149)]:
|
|
customers[b].update({
|
|
"first_name": customers[a]["first_name"],
|
|
"last_name": customers[a]["last_name"],
|
|
"email": customers[a]["email"],
|
|
"postal_code": customers[a]["postal_code"],
|
|
"city": customers[a]["city"],
|
|
})
|
|
|
|
vehicles = []
|
|
statuses = ["available"] * 22 + ["rented"] * 10 + ["cleaning"] * 6 + ["maintenance"] * 6 + ["blocked"] * 6
|
|
rnd.shuffle(statuses)
|
|
for i in range(1, 51):
|
|
make, model = MAKES_MODELS[(i - 1) % len(MAKES_MODELS)]
|
|
km = 18000 + i * 730 + rnd.randint(0, 900)
|
|
vehicles.append({
|
|
"public_ref": f"MO-{i:03d}",
|
|
"make": make,
|
|
"model": model,
|
|
"model_year": 2019 + (i % 7),
|
|
"registration_number": f"2-MOB-{i:03d}",
|
|
"location": "Geel",
|
|
"operational_status": statuses[i - 1],
|
|
"odometer_km": km,
|
|
"next_service_km": ((km // 10000) + 1) * 10000,
|
|
"active": "true",
|
|
})
|
|
|
|
# Fixed return scenario.
|
|
vehicles[23]["operational_status"] = "rented" # MO-024
|
|
vehicles[23]["odometer_km"] = 54820
|
|
# Fixed attention and conflict scenarios.
|
|
vehicles[30]["operational_status"] = "blocked" # MO-031
|
|
vehicles[15]["operational_status"] = "available" # MO-016 legacy conflict
|
|
|
|
bookings = []
|
|
base_dt = datetime.combine(anchor, time(9, 0), tzinfo=timezone.utc)
|
|
# 220 historical bookings.
|
|
for i in range(1, 221):
|
|
end = base_dt - timedelta(days=2 + (i % 320), hours=i % 8)
|
|
duration = 2 + (i % 10)
|
|
start = end - timedelta(days=duration)
|
|
vehicle_idx = (i * 7) % 50
|
|
start_km = int(vehicles[vehicle_idx]["odometer_km"]) - 3000 - (i % 700)
|
|
end_km = start_km + 200 + (i % 900)
|
|
bookings.append({
|
|
"public_ref": f"BK-H-{i:04d}",
|
|
"customer_ref": f"CUS-{((i * 11) % 180) + 1:04d}",
|
|
"vehicle_ref": f"MO-{vehicle_idx + 1:03d}",
|
|
"starts_at": iso(start),
|
|
"ends_at": iso(end),
|
|
"status": "returned",
|
|
"start_odometer_km": start_km,
|
|
"end_odometer_km": end_km,
|
|
"requirements_complete": "true",
|
|
})
|
|
|
|
# Active demo return booking.
|
|
bookings.append({
|
|
"public_ref": "BK-DEMO-RETURN",
|
|
"customer_ref": "CUS-0042",
|
|
"vehicle_ref": "MO-024",
|
|
"starts_at": iso(base_dt - timedelta(days=4)),
|
|
"ends_at": iso(base_dt),
|
|
"status": "active",
|
|
"start_odometer_km": 53610,
|
|
"end_odometer_km": "",
|
|
"requirements_complete": "true",
|
|
})
|
|
|
|
# 22 additional future bookings.
|
|
for i in range(1, 23):
|
|
start = base_dt + timedelta(days=1 + i, hours=(i % 5))
|
|
end = start + timedelta(days=3 + (i % 8))
|
|
vehicle = 1 + ((i * 9) % 50)
|
|
bookings.append({
|
|
"public_ref": f"BK-F-{i:03d}",
|
|
"customer_ref": f"CUS-{((i * 13) % 180) + 1:04d}",
|
|
"vehicle_ref": f"MO-{vehicle:03d}",
|
|
"starts_at": iso(start),
|
|
"ends_at": iso(end),
|
|
"status": "reserved",
|
|
"start_odometer_km": "",
|
|
"end_odometer_km": "",
|
|
"requirements_complete": "false" if i in (3, 11) else "true",
|
|
})
|
|
|
|
# MO-031 near-future booking, missing return inspection attention.
|
|
bookings.append({
|
|
"public_ref": "BK-DEMO-NEXT",
|
|
"customer_ref": "CUS-0088",
|
|
"vehicle_ref": "MO-031",
|
|
"starts_at": iso(base_dt + timedelta(days=1, hours=1)),
|
|
"ends_at": iso(base_dt + timedelta(days=6)),
|
|
"status": "reserved",
|
|
"start_odometer_km": "",
|
|
"end_odometer_km": "",
|
|
"requirements_complete": "true",
|
|
})
|
|
|
|
# Controlled legacy overlap on MO-016.
|
|
for suffix, offset in [("A", 2), ("B", 4)]:
|
|
bookings.append({
|
|
"public_ref": f"BK-DEMO-OVERLAP-{suffix}",
|
|
"customer_ref": "CUS-0101" if suffix == "A" else "CUS-0102",
|
|
"vehicle_ref": "MO-016",
|
|
"starts_at": iso(base_dt + timedelta(days=offset)),
|
|
"ends_at": iso(base_dt + timedelta(days=offset + 5)),
|
|
"status": "reserved",
|
|
"start_odometer_km": "",
|
|
"end_odometer_km": "",
|
|
"requirements_complete": "true",
|
|
})
|
|
|
|
inspections = []
|
|
for i, booking in enumerate(bookings[:75], 1):
|
|
inspections.append({
|
|
"public_ref": f"INSP-{i:04d}",
|
|
"booking_ref": booking["public_ref"],
|
|
"vehicle_ref": booking["vehicle_ref"],
|
|
"type": "return",
|
|
"fuel_level_percent": 50 + (i % 5) * 10,
|
|
"cleanliness_ok": "true",
|
|
"damage_reported": "false",
|
|
"technical_warning": "false",
|
|
"odometer_km": booking["end_odometer_km"] or "",
|
|
"completed_at": booking["ends_at"],
|
|
})
|
|
|
|
maintenance = []
|
|
for i in range(1, 41):
|
|
v = vehicles[(i * 3) % 50]
|
|
maintenance.append({
|
|
"public_ref": f"MNT-{i:04d}",
|
|
"vehicle_ref": v["public_ref"],
|
|
"occurred_at": iso(base_dt - timedelta(days=20 + i * 5)),
|
|
"odometer_km": int(v["odometer_km"]) - 1000 - i * 20,
|
|
"category": "periodic_service" if i % 3 else "repair",
|
|
"summary": "Synthetic scheduled service record" if i % 3 else "Synthetic minor repair record",
|
|
})
|
|
|
|
quality = [
|
|
{"public_ref":"DQ-DEMO-DUPLICATE","rule_type":"possible_duplicate_customer","entity_ref":"CUS-0012","related_ref":"CUS-0178","severity":"high","status":"open","evidence":"exact email; exact phone; exact postal code; similar name"},
|
|
{"public_ref":"DQ-DEMO-OVERLAP","rule_type":"booking_overlap","entity_ref":"MO-016","related_ref":"BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B","severity":"high","status":"open","evidence":"controlled legacy import overlap"},
|
|
{"public_ref":"DQ-DEMO-STATUS","rule_type":"vehicle_status_conflict","entity_ref":"MO-016","related_ref":"","severity":"high","status":"open","evidence":"vehicle marked available while reserved bookings conflict"},
|
|
{"public_ref":"DQ-DEMO-ATTENTION","rule_type":"missing_required_field","entity_ref":"MO-031","related_ref":"BK-DEMO-NEXT","severity":"high","status":"open","evidence":"near-future booking; required operational inspection missing"},
|
|
]
|
|
for i in range(5, 16):
|
|
quality.append({
|
|
"public_ref": f"DQ-{i:04d}",
|
|
"rule_type": ["missing_required_field","odometer_regression","vehicle_status_conflict"][i % 3],
|
|
"entity_ref": f"MO-{((i * 7) % 50) + 1:03d}",
|
|
"related_ref": "",
|
|
"severity": ["low","medium","high"][i % 3],
|
|
"status": "open" if i < 12 else "resolved",
|
|
"evidence": "Synthetic deterministic seed issue",
|
|
})
|
|
|
|
workflow_runs = []
|
|
for i in range(1, 21):
|
|
status = "succeeded"
|
|
error = ""
|
|
if i == 20:
|
|
status = "failed"
|
|
error = "Synthetic connection timeout to n8n"
|
|
workflow_runs.append({
|
|
"event_id": f"00000000-0000-4000-8000-{i:012d}",
|
|
"event_type": "vehicle.returned.v1",
|
|
"aggregate_ref": f"BK-H-{i:04d}",
|
|
"status": status,
|
|
"attempts": 3 if status == "failed" else 1,
|
|
"last_error": error,
|
|
"occurred_at": iso(base_dt - timedelta(hours=i)),
|
|
})
|
|
|
|
write_csv(out / "customers.csv", customers)
|
|
write_csv(out / "vehicles.csv", vehicles)
|
|
write_csv(out / "bookings.csv", bookings)
|
|
write_csv(out / "inspections.csv", inspections)
|
|
write_csv(out / "maintenance.csv", maintenance)
|
|
write_csv(out / "data_quality_issues.csv", quality)
|
|
write_csv(out / "workflow_runs.csv", workflow_runs)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--anchor", type=date.fromisoformat, default=date(2026, 8, 1))
|
|
parser.add_argument("--seed", type=int, default=20260801)
|
|
parser.add_argument("--out", type=Path, default=Path(__file__).resolve().parent)
|
|
args = parser.parse_args()
|
|
build(args.anchor, args.seed, args.out)
|