| 1 | #!/usr/bin/env python3
|
|---|
| 2 | # /// script
|
|---|
| 3 | # requires-python = ">=3.13"
|
|---|
| 4 | # dependencies = [
|
|---|
| 5 | # "faker==40.38.0",
|
|---|
| 6 | # ]
|
|---|
| 7 | # ///
|
|---|
| 8 | """Generate synthetic vendor, client, project, review and dispute data as CSV files.
|
|---|
| 9 |
|
|---|
| 10 | Usage: uv run seed_generator.py [out_dir] [--scale FLOAT] [--seed INT]
|
|---|
| 11 |
|
|---|
| 12 | The output is deterministic: every date is derived from REFERENCE_DATE instead of
|
|---|
| 13 | the clock, and both random and Faker are seeded, so the same seed, scale and code
|
|---|
| 14 | always produce the same files.
|
|---|
| 15 | """
|
|---|
| 16 |
|
|---|
| 17 | import argparse
|
|---|
| 18 | import csv
|
|---|
| 19 | import hashlib
|
|---|
| 20 | import math
|
|---|
| 21 | import random
|
|---|
| 22 | import re
|
|---|
| 23 | import time
|
|---|
| 24 | from array import array
|
|---|
| 25 | from contextlib import contextmanager
|
|---|
| 26 | from datetime import date, datetime, timedelta
|
|---|
| 27 | from pathlib import Path
|
|---|
| 28 |
|
|---|
| 29 | from faker import Faker
|
|---|
| 30 |
|
|---|
| 31 | # ── CONFIG ────────────────────────────────────────────────────────────────────
|
|---|
| 32 | REFERENCE_DATE = date(2026, 5, 13) # fixed "today" for the generated data, for reproducible dates
|
|---|
| 33 | REFERENCE_END = datetime.combine(REFERENCE_DATE, datetime.max.time()).replace(microsecond=0)
|
|---|
| 34 | HISTORY_DAYS = 4 * 365 # maximum lookback for generated start dates
|
|---|
| 35 | PROGRESS_EVERY = 100_000
|
|---|
| 36 |
|
|---|
| 37 | fake = Faker()
|
|---|
| 38 |
|
|---|
| 39 |
|
|---|
| 40 | # ── TABLE SIZES ───────────────────────────────────────────────────────────────
|
|---|
| 41 | # Target sizes at --scale 1.0; --scale multiplies each of them (minimum 1).
|
|---|
| 42 | # The number of reviews is capped by the number of completed projects. The
|
|---|
| 43 | # lookup tables are fixed lists and do not scale.
|
|---|
| 44 | NUM_VENDORS = 5_000
|
|---|
| 45 | NUM_CLIENTS = 20_000
|
|---|
| 46 | NUM_CLIENT_USERS = 50_000
|
|---|
| 47 | NUM_VENDOR_USERS = 25_000
|
|---|
| 48 | NUM_MGMT_USERS = 2_000
|
|---|
| 49 | NUM_CONTRACTS = 250_000
|
|---|
| 50 | NUM_PROJECTS = 1_400_000
|
|---|
| 51 | NUM_REVIEWS = 1_000_000
|
|---|
| 52 |
|
|---|
| 53 | # Probability that a review dated before REFERENCE_DATE gets a dispute.
|
|---|
| 54 | DISPUTE_RATE = 0.15
|
|---|
| 55 |
|
|---|
| 56 |
|
|---|
| 57 | # ── HELPERS ───────────────────────────────────────────────────────────────────
|
|---|
| 58 | def scaled(n: int, scale: float) -> int:
|
|---|
| 59 | return max(1, round(n * scale))
|
|---|
| 60 |
|
|---|
| 61 |
|
|---|
| 62 | def positive_float(text: str) -> float:
|
|---|
| 63 | value = float(text)
|
|---|
| 64 | if value <= 0:
|
|---|
| 65 | raise argparse.ArgumentTypeError("must be greater than 0")
|
|---|
| 66 | return value
|
|---|
| 67 |
|
|---|
| 68 |
|
|---|
| 69 | @contextmanager
|
|---|
| 70 | def csv_file(out_dir: Path, filename: str, header: list[str]):
|
|---|
| 71 | with (out_dir / filename).open("w", newline="", encoding="utf-8") as f:
|
|---|
| 72 | writer = csv.writer(f)
|
|---|
| 73 | writer.writerow(header)
|
|---|
| 74 | yield writer
|
|---|
| 75 |
|
|---|
| 76 |
|
|---|
| 77 | def report(filename: str, rows: int, t0: float) -> None:
|
|---|
| 78 | print(f" ✓ {filename:<48} {rows:>12,} rows ({time.time() - t0:.1f}s)")
|
|---|
| 79 |
|
|---|
| 80 |
|
|---|
| 81 | def write_table(out_dir: Path, filename: str, header: list[str], rows: list) -> None:
|
|---|
| 82 | t0 = time.time()
|
|---|
| 83 | with csv_file(out_dir, filename, header) as writer:
|
|---|
| 84 | writer.writerows(rows)
|
|---|
| 85 | report(filename, len(rows), t0)
|
|---|
| 86 |
|
|---|
| 87 |
|
|---|
| 88 | def day_between(start: date, end: date) -> date:
|
|---|
| 89 | return start + timedelta(days=random.randint(0, (end - start).days))
|
|---|
| 90 |
|
|---|
| 91 |
|
|---|
| 92 | def ts_on(day: date) -> datetime:
|
|---|
| 93 | """A timestamp during office hours (08:00-18:00) on the given day."""
|
|---|
| 94 | return datetime.combine(day, datetime.min.time()) + timedelta(seconds=random.randint(8 * 3600, 18 * 3600))
|
|---|
| 95 |
|
|---|
| 96 |
|
|---|
| 97 | def ts_between(start: datetime, end: datetime) -> datetime:
|
|---|
| 98 | return start + timedelta(seconds=random.randint(0, int((end - start).total_seconds())))
|
|---|
| 99 |
|
|---|
| 100 |
|
|---|
| 101 | def ts_after(start: datetime, max_days: int) -> datetime:
|
|---|
| 102 | """A timestamp up to max_days after start, never past REFERENCE_END."""
|
|---|
| 103 | return ts_between(start, min(start + timedelta(days=max_days), REFERENCE_END))
|
|---|
| 104 |
|
|---|
| 105 |
|
|---|
| 106 | def ts_strictly_between(start: datetime, end: datetime, count: int) -> list[datetime]:
|
|---|
| 107 | """count distinct timestamps in ascending order, strictly between start and end."""
|
|---|
| 108 | if count == 0:
|
|---|
| 109 | return []
|
|---|
| 110 | offsets = random.sample(range(1, int((end - start).total_seconds())), count)
|
|---|
| 111 | return [start + timedelta(seconds=s) for s in sorted(offsets)]
|
|---|
| 112 |
|
|---|
| 113 |
|
|---|
| 114 | def money(value: float) -> str:
|
|---|
| 115 | return f"{value:.2f}"
|
|---|
| 116 |
|
|---|
| 117 |
|
|---|
| 118 | def slug(text: str) -> str:
|
|---|
| 119 | return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|---|
| 120 |
|
|---|
| 121 |
|
|---|
| 122 | def password_hash() -> str:
|
|---|
| 123 | return hashlib.sha256(fake.password().encode()).hexdigest()
|
|---|
| 124 |
|
|---|
| 125 |
|
|---|
| 126 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 127 | # 1. LOOKUP TABLES
|
|---|
| 128 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 129 | ROLES = [
|
|---|
| 130 | (1, "Admin", "Full platform access"),
|
|---|
| 131 | (2, "Support", "Customer support operations"),
|
|---|
| 132 | (3, "Finance", "Financial oversight and reporting"),
|
|---|
| 133 | (4, "Operations", "Day-to-day operational management"),
|
|---|
| 134 | (5, "Compliance", "Auditing, legal, and compliance"),
|
|---|
| 135 | ]
|
|---|
| 136 | ROLE_IDS = [r[0] for r in ROLES]
|
|---|
| 137 | ROLE_WEIGHTS = [5, 40, 15, 25, 15] # relative sampling weights, in ROLES order
|
|---|
| 138 |
|
|---|
| 139 | PERMISSIONS = [
|
|---|
| 140 | "view_projects",
|
|---|
| 141 | "edit_projects",
|
|---|
| 142 | "delete_projects",
|
|---|
| 143 | "view_reviews",
|
|---|
| 144 | "publish_reviews",
|
|---|
| 145 | "delete_reviews",
|
|---|
| 146 | "manage_users",
|
|---|
| 147 | "view_financials",
|
|---|
| 148 | "manage_subscriptions",
|
|---|
| 149 | "resolve_disputes",
|
|---|
| 150 | "assign_tickets",
|
|---|
| 151 | "view_reports",
|
|---|
| 152 | "export_data",
|
|---|
| 153 | "manage_roles",
|
|---|
| 154 | "impersonate_user",
|
|---|
| 155 | ]
|
|---|
| 156 |
|
|---|
| 157 | ROLE_PERMISSIONS = {
|
|---|
| 158 | "Admin": PERMISSIONS,
|
|---|
| 159 | "Support": [
|
|---|
| 160 | "view_projects", "view_reviews", "view_reports", "assign_tickets", "resolve_disputes",
|
|---|
| 161 | ],
|
|---|
| 162 | "Finance": [
|
|---|
| 163 | "view_projects", "view_financials", "view_reports", "export_data", "manage_subscriptions",
|
|---|
| 164 | ],
|
|---|
| 165 | "Operations": [
|
|---|
| 166 | "view_projects", "edit_projects", "delete_projects", "view_reviews", "publish_reviews",
|
|---|
| 167 | "assign_tickets", "view_reports", "manage_users",
|
|---|
| 168 | ],
|
|---|
| 169 | "Compliance": [
|
|---|
| 170 | "view_projects", "view_reviews", "delete_reviews", "view_financials", "view_reports",
|
|---|
| 171 | "export_data", "resolve_disputes",
|
|---|
| 172 | ],
|
|---|
| 173 | }
|
|---|
| 174 |
|
|---|
| 175 | INDUSTRIES = [
|
|---|
| 176 | "Technology",
|
|---|
| 177 | "Healthcare",
|
|---|
| 178 | "Finance",
|
|---|
| 179 | "Retail",
|
|---|
| 180 | "Manufacturing",
|
|---|
| 181 | "Education",
|
|---|
| 182 | "Real Estate",
|
|---|
| 183 | "Energy",
|
|---|
| 184 | "Transportation",
|
|---|
| 185 | "Media",
|
|---|
| 186 | "Hospitality",
|
|---|
| 187 | "Agriculture",
|
|---|
| 188 | "Construction",
|
|---|
| 189 | "Pharmaceuticals",
|
|---|
| 190 | "Telecommunications",
|
|---|
| 191 | "Automotive",
|
|---|
| 192 | "Aerospace",
|
|---|
| 193 | "Legal Services",
|
|---|
| 194 | "Non-Profit",
|
|---|
| 195 | "Government",
|
|---|
| 196 | ]
|
|---|
| 197 |
|
|---|
| 198 | STATUSES = ["Draft", "In Progress", "On Hold", "Completed", "Cancelled", "Under Review"]
|
|---|
| 199 | STATUS_ID = {name: i for i, name in enumerate(STATUSES, start=1)}
|
|---|
| 200 | FINISHED = {"Completed", "Cancelled"}
|
|---|
| 201 |
|
|---|
| 202 | # The status a project has reached by the reference day, and how likely each is.
|
|---|
| 203 | # status_path builds the statuses that led there: a prefix of
|
|---|
| 204 | # Draft -> Under Review -> In Progress, optional hold/resume cycles, and a
|
|---|
| 205 | # cancellation from any intermediate stage.
|
|---|
| 206 | OUTCOMES = ["Completed", "Cancelled", "In Progress", "On Hold", "Under Review", "Draft"]
|
|---|
| 207 | OUTCOME_WEIGHTS = [72, 5, 13, 4, 3, 3]
|
|---|
| 208 | PROJECT_DAYS = {"Completed": (30, 730), "Cancelled": (7, 365)} # min/max duration of a finished project
|
|---|
| 209 |
|
|---|
| 210 | STATUS_COMMENTS = {
|
|---|
| 211 | "Draft": [
|
|---|
| 212 | "Project created from the contract scope.",
|
|---|
| 213 | "Initial draft entered by the vendor.",
|
|---|
| 214 | "Scope drafted; awaiting client review.",
|
|---|
| 215 | ],
|
|---|
| 216 | "Under Review": [
|
|---|
| 217 | "Scope and budget sent to the client for approval.",
|
|---|
| 218 | "Under review by the client's steering committee.",
|
|---|
| 219 | "Awaiting sign-off on the statement of work.",
|
|---|
| 220 | ],
|
|---|
| 221 | "In Progress": [
|
|---|
| 222 | "Kick-off meeting held; work started.",
|
|---|
| 223 | "Approved by the client; development under way.",
|
|---|
| 224 | "Resumed after the hold was lifted.",
|
|---|
| 225 | "Sprint work in progress.",
|
|---|
| 226 | ],
|
|---|
| 227 | "On Hold": [
|
|---|
| 228 | "Paused at the client's request.",
|
|---|
| 229 | "Waiting for third-party API access.",
|
|---|
| 230 | "On hold until the budget revision is approved.",
|
|---|
| 231 | ],
|
|---|
| 232 | "Completed": [
|
|---|
| 233 | "All deliverables accepted by the client.",
|
|---|
| 234 | "Final release deployed and handed over.",
|
|---|
| 235 | "Closed after the acceptance test.",
|
|---|
| 236 | ],
|
|---|
| 237 | "Cancelled": [
|
|---|
| 238 | "Cancelled by the client before delivery.",
|
|---|
| 239 | "Cancelled: the requirements changed substantially.",
|
|---|
| 240 | "Contract terminated; project cancelled.",
|
|---|
| 241 | ],
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | TECHNOLOGIES = [
|
|---|
| 245 | "Python",
|
|---|
| 246 | "JavaScript",
|
|---|
| 247 | "TypeScript",
|
|---|
| 248 | "Java",
|
|---|
| 249 | "C#",
|
|---|
| 250 | "Go",
|
|---|
| 251 | "Rust",
|
|---|
| 252 | "PHP",
|
|---|
| 253 | "Ruby",
|
|---|
| 254 | "Swift",
|
|---|
| 255 | "Kotlin",
|
|---|
| 256 | "React",
|
|---|
| 257 | "Angular",
|
|---|
| 258 | "Vue.js",
|
|---|
| 259 | "Node.js",
|
|---|
| 260 | "Django",
|
|---|
| 261 | "FastAPI",
|
|---|
| 262 | "Spring Boot",
|
|---|
| 263 | "PostgreSQL",
|
|---|
| 264 | "MySQL",
|
|---|
| 265 | "MongoDB",
|
|---|
| 266 | "Redis",
|
|---|
| 267 | "Elasticsearch",
|
|---|
| 268 | "Docker",
|
|---|
| 269 | "Kubernetes",
|
|---|
| 270 | "AWS",
|
|---|
| 271 | "Azure",
|
|---|
| 272 | "GCP",
|
|---|
| 273 | "Terraform",
|
|---|
| 274 | "GraphQL",
|
|---|
| 275 | ]
|
|---|
| 276 | TECH_IDS = list(range(1, len(TECHNOLOGIES) + 1))
|
|---|
| 277 |
|
|---|
| 278 | # (dimension_id, name, description, bias). The bias shifts every vendor's mean
|
|---|
| 279 | # score on that dimension; the synthetic biases favour communication over
|
|---|
| 280 | # deadline adherence and documentation.
|
|---|
| 281 | DIMENSIONS = [
|
|---|
| 282 | (1, "Code Quality", "Quality, readability and maintainability of the delivered code", 0.00),
|
|---|
| 283 | (2, "Communication", "Clarity and frequency of communication throughout the project", 0.20),
|
|---|
| 284 | (3, "Deadline Adherence", "Delivery of milestones and the final work on schedule", -0.25),
|
|---|
| 285 | (4, "Overall Satisfaction", "Overall satisfaction with the vendor and the delivered result", 0.10),
|
|---|
| 286 | (5, "Budget Adherence", "Accuracy to the originally quoted budget", -0.20),
|
|---|
| 287 | (6, "Technical Skill", "Depth and breadth of technical expertise demonstrated", 0.15),
|
|---|
| 288 | (7, "Responsiveness", "Speed and attentiveness when addressing client queries", 0.10),
|
|---|
| 289 | (8, "Documentation", "Quality and completeness of the technical documentation delivered", -0.30),
|
|---|
| 290 | (9, "Problem Solving", "Effectiveness at resolving blockers and unexpected challenges", 0.00),
|
|---|
| 291 | (10, "Team Collaboration", "How well the vendor team collaborated with client stakeholders", 0.05),
|
|---|
| 292 | ]
|
|---|
| 293 |
|
|---|
| 294 | TIERS = [
|
|---|
| 295 | (1, "Starter", 99.00, False),
|
|---|
| 296 | (2, "Professional", 299.00, False),
|
|---|
| 297 | (3, "Business", None, True),
|
|---|
| 298 | (4, "Enterprise", None, True),
|
|---|
| 299 | ]
|
|---|
| 300 | TIER_IDS = [t[0] for t in TIERS]
|
|---|
| 301 | CUSTOM_PRICE_RANGE = {3: (350, 1_500), 4: (1_500, 6_000)}
|
|---|
| 302 | SUBSCRIPTION_DAYS = [30, 90, 180, 365]
|
|---|
| 303 |
|
|---|
| 304 | CURRENCIES = ["USD", "EUR", "GBP", "CHF", "CAD", "AUD"]
|
|---|
| 305 | CURRENCY_WEIGHTS = [40, 35, 10, 5, 5, 5]
|
|---|
| 306 | TLDS = ["com", "io", "net", "co", "dev"]
|
|---|
| 307 |
|
|---|
| 308 |
|
|---|
| 309 | def write_lookup_tables(out_dir: Path) -> None:
|
|---|
| 310 | write_table(out_dir, "role.csv", ["role_id", "role_name", "description"], ROLES)
|
|---|
| 311 | write_table(out_dir, "permission.csv", ["permission_id", "action_name"],
|
|---|
| 312 | list(enumerate(PERMISSIONS, start=1)))
|
|---|
| 313 | write_table(out_dir, "industry.csv", ["industry_id", "industry_name"],
|
|---|
| 314 | list(enumerate(INDUSTRIES, start=1)))
|
|---|
| 315 | write_table(out_dir, "project_status.csv", ["status_id", "status_name"],
|
|---|
| 316 | list(enumerate(STATUSES, start=1)))
|
|---|
| 317 | write_table(out_dir, "technology.csv", ["technology_id", "technology_name"],
|
|---|
| 318 | list(enumerate(TECHNOLOGIES, start=1)))
|
|---|
| 319 | write_table(out_dir, "rating_dimension.csv", ["dimension_id", "dimension_name", "description"],
|
|---|
| 320 | [d[:3] for d in DIMENSIONS])
|
|---|
| 321 | write_table(out_dir, "subscription_tier.csv", ["tier_id", "tier_name", "list_price", "allows_custom_pricing"],
|
|---|
| 322 | TIERS)
|
|---|
| 323 | permission_id = {name: i for i, name in enumerate(PERMISSIONS, start=1)}
|
|---|
| 324 | write_table(out_dir, "role_permission.csv", ["role_id", "permission_id"],
|
|---|
| 325 | [(role_id, permission_id[p]) for role_id, role_name, _ in ROLES for p in ROLE_PERMISSIONS[role_name]])
|
|---|
| 326 |
|
|---|
| 327 |
|
|---|
| 328 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 329 | # 2. VENDORS & CLIENTS
|
|---|
| 330 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 331 | def write_vendors(out_dir: Path, n_vendors: int) -> tuple[list[str], list[float]]:
|
|---|
| 332 | """Returns each vendor's e-mail domain and its latent quality: the mean score
|
|---|
| 333 | its reviews are drawn around."""
|
|---|
| 334 | t0 = time.time()
|
|---|
| 335 | domains, quality = [], []
|
|---|
| 336 | with csv_file(out_dir, "vendor.csv", ["vendor_id", "agency_name", "website"]) as writer:
|
|---|
| 337 | for vendor_id in range(1, n_vendors + 1):
|
|---|
| 338 | name = fake.company()
|
|---|
| 339 | domain = f"{slug(name)}.{random.choice(TLDS)}"
|
|---|
| 340 | writer.writerow([vendor_id, name, f"https://www.{domain}/"])
|
|---|
| 341 | domains.append(domain)
|
|---|
| 342 | quality.append(min(4.8, max(1.5, random.gauss(3.6, 0.6))))
|
|---|
| 343 | report("vendor.csv", n_vendors, t0)
|
|---|
| 344 | return domains, quality
|
|---|
| 345 |
|
|---|
| 346 |
|
|---|
| 347 | def write_clients(out_dir: Path, n_clients: int) -> list[str]:
|
|---|
| 348 | """Returns each client's e-mail domain."""
|
|---|
| 349 | t0 = time.time()
|
|---|
| 350 | domains = []
|
|---|
| 351 | with csv_file(out_dir, "client.csv", ["client_id", "industry_id", "company_name", "contact_email"]) as writer:
|
|---|
| 352 | for client_id in range(1, n_clients + 1):
|
|---|
| 353 | name = fake.company()
|
|---|
| 354 | domain = f"{slug(name)}.{random.choice(TLDS)}"
|
|---|
| 355 | mailbox = random.choice(["info", "contact", "office"])
|
|---|
| 356 | writer.writerow([client_id, random.randint(1, len(INDUSTRIES)), name, f"{mailbox}@{domain}"])
|
|---|
| 357 | domains.append(domain)
|
|---|
| 358 | report("client.csv", n_clients, t0)
|
|---|
| 359 | return domains
|
|---|
| 360 |
|
|---|
| 361 |
|
|---|
| 362 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 363 | # 3. USERS
|
|---|
| 364 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 365 | USER_COLUMNS = [
|
|---|
| 366 | "user_id", "type", "first_name", "last_name", "email", "password_hash",
|
|---|
| 367 | "is_active", "last_login_at", "created_at", "updated_at",
|
|---|
| 368 | ]
|
|---|
| 369 |
|
|---|
| 370 |
|
|---|
| 371 | def write_users(
|
|---|
| 372 | out_dir: Path,
|
|---|
| 373 | n_client_users: int,
|
|---|
| 374 | n_vendor_users: int,
|
|---|
| 375 | n_mgmt_users: int,
|
|---|
| 376 | client_domains: list[str],
|
|---|
| 377 | vendor_domains: list[str],
|
|---|
| 378 | ) -> tuple[list[list[int]], list[list[int]], list[int]]:
|
|---|
| 379 | """Writes user.csv and the three subtype files. Users are dealt round-robin over
|
|---|
| 380 | the companies, so with at least as many users as companies every client and
|
|---|
| 381 | every vendor gets a user. Returns the user ids per client, per vendor, and the
|
|---|
| 382 | management user ids."""
|
|---|
| 383 | t0 = time.time()
|
|---|
| 384 | earliest = datetime.combine(REFERENCE_DATE - timedelta(days=HISTORY_DAYS + 30), datetime.min.time())
|
|---|
| 385 | used_emails: set[str] = set()
|
|---|
| 386 | client_to_users: list[list[int]] = [[] for _ in client_domains]
|
|---|
| 387 | vendor_to_users: list[list[int]] = [[] for _ in vendor_domains]
|
|---|
| 388 | mgmt_user_ids: list[int] = []
|
|---|
| 389 | user_id = 0
|
|---|
| 390 |
|
|---|
| 391 | def user_row(user_type: str, domain: str) -> list:
|
|---|
| 392 | nonlocal user_id
|
|---|
| 393 | user_id += 1
|
|---|
| 394 | first, last = fake.first_name(), fake.last_name()
|
|---|
| 395 | local = slug(f"{first} {last}").replace("-", ".")
|
|---|
| 396 | email, n = f"{local}@{domain}", 1
|
|---|
| 397 | while email in used_emails:
|
|---|
| 398 | n += 1
|
|---|
| 399 | email = f"{local}{n}@{domain}"
|
|---|
| 400 | used_emails.add(email)
|
|---|
| 401 | created = ts_between(earliest, REFERENCE_END)
|
|---|
| 402 | # inactive seed users are modelled as unconfirmed, with no login history
|
|---|
| 403 | is_active = random.random() < 0.85
|
|---|
| 404 | last_login = ts_between(created, REFERENCE_END) if is_active and random.random() < 0.90 else None
|
|---|
| 405 | updated = ts_between(last_login or created, REFERENCE_END)
|
|---|
| 406 | return [user_id, user_type, first, last, email, password_hash(), is_active, last_login, created, updated]
|
|---|
| 407 |
|
|---|
| 408 | with (
|
|---|
| 409 | csv_file(out_dir, "user.csv", USER_COLUMNS) as users,
|
|---|
| 410 | csv_file(out_dir, "client_user.csv", ["user_id", "client_id"]) as client_users,
|
|---|
| 411 | csv_file(out_dir, "vendor_user.csv", ["user_id", "vendor_id"]) as vendor_users,
|
|---|
| 412 | csv_file(out_dir, "management_user.csv", ["user_id", "role_id"]) as mgmt_users,
|
|---|
| 413 | ):
|
|---|
| 414 | for i in range(n_client_users):
|
|---|
| 415 | client_id = i % len(client_domains) + 1
|
|---|
| 416 | row = user_row("client", client_domains[client_id - 1])
|
|---|
| 417 | users.writerow(row)
|
|---|
| 418 | client_users.writerow([row[0], client_id])
|
|---|
| 419 | client_to_users[client_id - 1].append(row[0])
|
|---|
| 420 | for i in range(n_vendor_users):
|
|---|
| 421 | vendor_id = i % len(vendor_domains) + 1
|
|---|
| 422 | row = user_row("vendor", vendor_domains[vendor_id - 1])
|
|---|
| 423 | users.writerow(row)
|
|---|
| 424 | vendor_users.writerow([row[0], vendor_id])
|
|---|
| 425 | vendor_to_users[vendor_id - 1].append(row[0])
|
|---|
| 426 | for _ in range(n_mgmt_users):
|
|---|
| 427 | row = user_row("management", "vqems.com")
|
|---|
| 428 | users.writerow(row)
|
|---|
| 429 | mgmt_users.writerow([row[0], random.choices(ROLE_IDS, ROLE_WEIGHTS)[0]])
|
|---|
| 430 | mgmt_user_ids.append(row[0])
|
|---|
| 431 | report("user.csv", user_id, t0)
|
|---|
| 432 | report("client_user.csv", n_client_users, t0)
|
|---|
| 433 | report("vendor_user.csv", n_vendor_users, t0)
|
|---|
| 434 | report("management_user.csv", n_mgmt_users, t0)
|
|---|
| 435 | return client_to_users, vendor_to_users, mgmt_user_ids
|
|---|
| 436 |
|
|---|
| 437 |
|
|---|
| 438 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 439 | # 4. VENDOR SUBSCRIPTIONS
|
|---|
| 440 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 441 | SUBSCRIPTION_COLUMNS = [
|
|---|
| 442 | "contract_id", "vendor_id", "tier_id", "negotiated_price", "start_date", "end_date",
|
|---|
| 443 | "is_active", "created_at", "updated_at",
|
|---|
| 444 | ]
|
|---|
| 445 |
|
|---|
| 446 |
|
|---|
| 447 | def write_subscriptions(out_dir: Path, n_vendors: int) -> None:
|
|---|
| 448 | """Up to three non-overlapping periods per vendor; only the last period can be
|
|---|
| 449 | active, and only while it has not ended."""
|
|---|
| 450 | t0 = time.time()
|
|---|
| 451 | contract_id = 0
|
|---|
| 452 | with csv_file(out_dir, "vendor_subscription.csv", SUBSCRIPTION_COLUMNS) as writer:
|
|---|
| 453 | for vendor_id in range(1, n_vendors + 1):
|
|---|
| 454 | periods = []
|
|---|
| 455 | start = REFERENCE_DATE - timedelta(days=random.randint(1, HISTORY_DAYS))
|
|---|
| 456 | for _ in range(random.choices([0, 1, 2, 3], weights=[10, 45, 30, 15])[0]):
|
|---|
| 457 | tier_id = random.choice(TIER_IDS)
|
|---|
| 458 | price = money(random.uniform(*CUSTOM_PRICE_RANGE[tier_id])) if tier_id in CUSTOM_PRICE_RANGE else None
|
|---|
| 459 | end = None if random.random() < 0.4 else start + timedelta(days=random.choice(SUBSCRIPTION_DAYS))
|
|---|
| 460 | periods.append((tier_id, price, start, end, ts_on(start)))
|
|---|
| 461 | if end is None:
|
|---|
| 462 | break # an open-ended period is the last one
|
|---|
| 463 | start = end + timedelta(days=random.randint(1, 60))
|
|---|
| 464 | if start > REFERENCE_DATE:
|
|---|
| 465 | break # that renewal has not happened yet
|
|---|
| 466 | for k, (tier_id, price, start, end, created) in enumerate(periods):
|
|---|
| 467 | contract_id += 1
|
|---|
| 468 | is_last = k == len(periods) - 1
|
|---|
| 469 | is_active = is_last and (end is None or end >= REFERENCE_DATE)
|
|---|
| 470 | updated = created if is_last else periods[k + 1][4] # deactivated when the next period was created
|
|---|
| 471 | writer.writerow([contract_id, vendor_id, tier_id, price, start, end, is_active, created, updated])
|
|---|
| 472 | report("vendor_subscription.csv", contract_id, t0)
|
|---|
| 473 |
|
|---|
| 474 |
|
|---|
| 475 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 476 | # 5. CONTRACTS & PROJECTS
|
|---|
| 477 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 478 | CONTRACT_COLUMNS = [
|
|---|
| 479 | "contract_id", "client_id", "vendor_id", "contract_number", "contract_title", "start_date", "end_date",
|
|---|
| 480 | "total_value", "currency_code", "terms_summary", "is_active", "created_at", "updated_at",
|
|---|
| 481 | ]
|
|---|
| 482 | PROJECT_COLUMNS = [
|
|---|
| 483 | "project_id", "contract_id", "status_id", "project_name", "start_date", "end_date", "budget",
|
|---|
| 484 | "created_at", "updated_at",
|
|---|
| 485 | ]
|
|---|
| 486 | AUDIT_COLUMNS = ["audit_id", "project_id", "old_budget", "new_budget", "created_at", "updated_at"]
|
|---|
| 487 | HISTORY_COLUMNS = [
|
|---|
| 488 | "project_status_history_id", "project_id", "vendor_user_id", "management_user_id", "status_id",
|
|---|
| 489 | "changed_at", "comment",
|
|---|
| 490 | ]
|
|---|
| 491 |
|
|---|
| 492 |
|
|---|
| 493 | def status_path(outcome: str) -> list[str]:
|
|---|
| 494 | """The ordered statuses a project went through to reach the given outcome."""
|
|---|
| 495 | if outcome == "Cancelled":
|
|---|
| 496 | stage = random.choice(["Draft", "Under Review", "In Progress", "On Hold"])
|
|---|
| 497 | return status_path(stage) + ["Cancelled"]
|
|---|
| 498 | path = ["Draft"]
|
|---|
| 499 | if outcome == "Draft":
|
|---|
| 500 | return path
|
|---|
| 501 | path.append("Under Review")
|
|---|
| 502 | if outcome == "Under Review":
|
|---|
| 503 | return path
|
|---|
| 504 | path.append("In Progress")
|
|---|
| 505 | for _ in range(random.choices([0, 1, 2], weights=[80, 15, 5])[0]):
|
|---|
| 506 | path += ["On Hold", "In Progress"]
|
|---|
| 507 | if outcome in ("On Hold", "Completed"):
|
|---|
| 508 | path.append(outcome)
|
|---|
| 509 | return path
|
|---|
| 510 |
|
|---|
| 511 |
|
|---|
| 512 | def draw_budget() -> int:
|
|---|
| 513 | """A project budget between 5,000 and 500,000, log-uniform and rounded to hundreds."""
|
|---|
| 514 | return int(round(math.exp(random.uniform(math.log(5_000), math.log(500_000))), -2))
|
|---|
| 515 |
|
|---|
| 516 |
|
|---|
| 517 | def revised_budget(budget: int) -> int:
|
|---|
| 518 | """A budget cut or raise, rounded to hundreds."""
|
|---|
| 519 | factor = random.uniform(0.7, 0.9) if random.random() < 0.4 else random.uniform(1.1, 1.6)
|
|---|
| 520 | return int(round(budget * factor, -2))
|
|---|
| 521 |
|
|---|
| 522 |
|
|---|
| 523 | def write_contracts_and_projects(
|
|---|
| 524 | out_dir: Path,
|
|---|
| 525 | n_contracts: int,
|
|---|
| 526 | n_projects: int,
|
|---|
| 527 | n_clients: int,
|
|---|
| 528 | n_vendors: int,
|
|---|
| 529 | vendor_to_users: list[list[int]],
|
|---|
| 530 | mgmt_user_ids: list[int],
|
|---|
| 531 | ) -> tuple[array, array, tuple[array, array, array]]:
|
|---|
| 532 | """One pass writes the contracts, their projects and the project technology,
|
|---|
| 533 | status history and budget audit files, so that (by convention of this seed)
|
|---|
| 534 | projects lie inside their contract's window, the contract value covers its
|
|---|
| 535 | project budgets, and the status history and budget audit of a project are
|
|---|
| 536 | consistent with the project row. Returns the client and vendor of every
|
|---|
| 537 | contract and the completed projects (id, contract id, end date ordinal)."""
|
|---|
| 538 | t0 = time.time()
|
|---|
| 539 | # every contract gets one project; the remaining projects are spread over the
|
|---|
| 540 | # contracts at random
|
|---|
| 541 | counts = array("i", [1]) * n_contracts
|
|---|
| 542 | for _ in range(max(0, n_projects - n_contracts)):
|
|---|
| 543 | counts[random.randrange(n_contracts)] += 1
|
|---|
| 544 |
|
|---|
| 545 | contract_client, contract_vendor = array("i"), array("i")
|
|---|
| 546 | completed_pid, completed_cid, completed_end = array("i"), array("i"), array("i")
|
|---|
| 547 | project_id = audit_id = history_id = n_technologies = 0
|
|---|
| 548 | print(f" dot = {PROGRESS_EVERY:,} projects: ", end="", flush=True)
|
|---|
| 549 | with (
|
|---|
| 550 | csv_file(out_dir, "client_vendor_contract.csv", CONTRACT_COLUMNS) as contracts,
|
|---|
| 551 | csv_file(out_dir, "project.csv", PROJECT_COLUMNS) as projects,
|
|---|
| 552 | csv_file(out_dir, "project_technology.csv", ["project_id", "technology_id"]) as technologies,
|
|---|
| 553 | csv_file(out_dir, "project_budget_audit.csv", AUDIT_COLUMNS) as audits,
|
|---|
| 554 | csv_file(out_dir, "project_status_history.csv", HISTORY_COLUMNS) as history,
|
|---|
| 555 | ):
|
|---|
| 556 | for contract_id in range(1, n_contracts + 1):
|
|---|
| 557 | client_id = random.randint(1, n_clients)
|
|---|
| 558 | vendor_id = random.randint(1, n_vendors)
|
|---|
| 559 | vendor_users = vendor_to_users[vendor_id - 1]
|
|---|
| 560 | c_start = REFERENCE_DATE - timedelta(days=random.randint(1, HISTORY_DAYS))
|
|---|
| 561 | c_end = None if random.random() < 0.15 else c_start + timedelta(days=random.randint(180, 1460))
|
|---|
| 562 | c_created = ts_on(c_start - timedelta(days=random.randint(0, 30)))
|
|---|
| 563 | contract_over = c_end is not None and c_end < REFERENCE_DATE
|
|---|
| 564 | # a finished project ends inside its contract and before the reference day;
|
|---|
| 565 | # projects start early enough to leave room for that where possible
|
|---|
| 566 | last_end = REFERENCE_DATE - timedelta(days=1)
|
|---|
| 567 | if c_end is not None:
|
|---|
| 568 | last_end = min(last_end, c_end)
|
|---|
| 569 | latest_start = max(c_start, last_end - timedelta(days=30))
|
|---|
| 570 | names: set[str] = set()
|
|---|
| 571 | budget_sum = 0
|
|---|
| 572 |
|
|---|
| 573 | for _ in range(counts[contract_id - 1]):
|
|---|
| 574 | project_id += 1
|
|---|
| 575 | p_start = day_between(c_start, latest_start)
|
|---|
| 576 | room = (last_end - p_start).days
|
|---|
| 577 | outcome = random.choices(OUTCOMES, OUTCOME_WEIGHTS)[0]
|
|---|
| 578 | if contract_over and outcome not in FINISHED:
|
|---|
| 579 | # nothing is still running under an expired contract
|
|---|
| 580 | outcome = random.choices(OUTCOMES[:2], OUTCOME_WEIGHTS[:2])[0]
|
|---|
| 581 | elif outcome in FINISHED and room < PROJECT_DAYS[outcome][0]:
|
|---|
| 582 | # the contract is too young for the project to have finished
|
|---|
| 583 | outcome = "In Progress"
|
|---|
| 584 | path = status_path(outcome)
|
|---|
| 585 | first = ts_on(p_start)
|
|---|
| 586 | if outcome in FINISHED:
|
|---|
| 587 | min_days, max_days = PROJECT_DAYS[outcome]
|
|---|
| 588 | p_end = p_start + timedelta(days=random.randint(min_days, min(max_days, room)))
|
|---|
| 589 | window_end = ts_on(p_end)
|
|---|
| 590 | stamps = [first, *ts_strictly_between(first, window_end, len(path) - 2), window_end]
|
|---|
| 591 | else:
|
|---|
| 592 | p_end = None
|
|---|
| 593 | window_end = REFERENCE_END
|
|---|
| 594 | stamps = [first, *ts_strictly_between(first, window_end, len(path) - 1)]
|
|---|
| 595 |
|
|---|
| 596 | # budget chain: each audit row starts where the previous one ended,
|
|---|
| 597 | # and the last new_budget is the project's current budget
|
|---|
| 598 | budgets = [draw_budget()]
|
|---|
| 599 | for _ in range(random.choices([0, 1, 2, 3], weights=[50, 30, 15, 5])[0]):
|
|---|
| 600 | budgets.append(revised_budget(budgets[-1]))
|
|---|
| 601 | changes = ts_strictly_between(first, window_end, len(budgets) - 1)
|
|---|
| 602 | for old_budget, new_budget, changed in zip(budgets, budgets[1:], changes):
|
|---|
| 603 | audit_id += 1
|
|---|
| 604 | audits.writerow([audit_id, project_id, old_budget, new_budget, changed, changed])
|
|---|
| 605 | budget_sum += budgets[-1]
|
|---|
| 606 |
|
|---|
| 607 | name = fake.catch_phrase()
|
|---|
| 608 | while name in names:
|
|---|
| 609 | name = fake.catch_phrase()
|
|---|
| 610 | names.add(name)
|
|---|
| 611 | updated = max(stamps[-1], changes[-1]) if changes else stamps[-1]
|
|---|
| 612 | projects.writerow([project_id, contract_id, STATUS_ID[path[-1]], name, p_start, p_end,
|
|---|
| 613 | budgets[-1], first, updated])
|
|---|
| 614 |
|
|---|
| 615 | for tech_id in random.sample(TECH_IDS, random.randint(1, 5)):
|
|---|
| 616 | technologies.writerow([project_id, tech_id])
|
|---|
| 617 | n_technologies += 1
|
|---|
| 618 |
|
|---|
| 619 | for status, changed in zip(path, stamps):
|
|---|
| 620 | history_id += 1
|
|---|
| 621 | if random.random() < 0.85:
|
|---|
| 622 | actor = (random.choice(vendor_users), None)
|
|---|
| 623 | else:
|
|---|
| 624 | actor = (None, random.choice(mgmt_user_ids))
|
|---|
| 625 | comment = random.choice(STATUS_COMMENTS[status]) if random.random() < 0.65 else None
|
|---|
| 626 | history.writerow([history_id, project_id, *actor, STATUS_ID[status], changed, comment])
|
|---|
| 627 |
|
|---|
| 628 | if outcome == "Completed":
|
|---|
| 629 | completed_pid.append(project_id)
|
|---|
| 630 | completed_cid.append(contract_id)
|
|---|
| 631 | completed_end.append(p_end.toordinal())
|
|---|
| 632 | if project_id % PROGRESS_EVERY == 0:
|
|---|
| 633 | print(".", end="", flush=True)
|
|---|
| 634 |
|
|---|
| 635 | # seed convention: a stated framework value exceeds the sum of the current
|
|---|
| 636 | # project budgets; some contracts state none
|
|---|
| 637 | total_value = None if random.random() < 0.10 else money(budget_sum * random.uniform(1.05, 1.5))
|
|---|
| 638 | contracts.writerow([
|
|---|
| 639 | contract_id, client_id, vendor_id, f"CTR-{contract_id:08d}", fake.bs().title(), c_start, c_end,
|
|---|
| 640 | total_value, random.choices(CURRENCIES, CURRENCY_WEIGHTS)[0], fake.paragraph(nb_sentences=2),
|
|---|
| 641 | c_end is None or c_end >= REFERENCE_DATE, c_created, c_created,
|
|---|
| 642 | ])
|
|---|
| 643 | contract_client.append(client_id)
|
|---|
| 644 | contract_vendor.append(vendor_id)
|
|---|
| 645 | print()
|
|---|
| 646 | report("client_vendor_contract.csv", n_contracts, t0)
|
|---|
| 647 | report("project.csv", project_id, t0)
|
|---|
| 648 | report("project_technology.csv", n_technologies, t0)
|
|---|
| 649 | report("project_budget_audit.csv", audit_id, t0)
|
|---|
| 650 | report("project_status_history.csv", history_id, t0)
|
|---|
| 651 | return contract_client, contract_vendor, (completed_pid, completed_cid, completed_end)
|
|---|
| 652 |
|
|---|
| 653 |
|
|---|
| 654 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 655 | # 6. REVIEWS, SCORES & DISPUTE TICKETS
|
|---|
| 656 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 657 | REVIEW_COLUMNS = [
|
|---|
| 658 | "review_id", "project_id", "client_user_id", "review_date", "summary_text", "is_published",
|
|---|
| 659 | "created_at", "updated_at",
|
|---|
| 660 | ]
|
|---|
| 661 | DISPUTE_COLUMNS = [
|
|---|
| 662 | "ticket_id", "assigned_management_user_id", "review_id", "vendor_user_id", "reason", "is_resolved",
|
|---|
| 663 | "filed_at", "resolved_at", "resolution_note", "created_at", "updated_at",
|
|---|
| 664 | ]
|
|---|
| 665 |
|
|---|
| 666 |
|
|---|
| 667 | def write_reviews(
|
|---|
| 668 | out_dir: Path,
|
|---|
| 669 | n_reviews: int,
|
|---|
| 670 | completed: tuple[array, array, array],
|
|---|
| 671 | contract_client: array,
|
|---|
| 672 | contract_vendor: array,
|
|---|
| 673 | client_to_users: list[list[int]],
|
|---|
| 674 | vendor_to_users: list[list[int]],
|
|---|
| 675 | mgmt_user_ids: list[int],
|
|---|
| 676 | vendor_quality: list[float],
|
|---|
| 677 | ) -> None:
|
|---|
| 678 | """Reviews go to a sample of the completed projects, 1-45 days after the end
|
|---|
| 679 | date, written by a user of the project's client. A vendor user of the project's
|
|---|
| 680 | vendor may dispute the review; while that ticket is open the review stays
|
|---|
| 681 | unpublished, otherwise about 70% of the reviews are published."""
|
|---|
| 682 | t0 = time.time()
|
|---|
| 683 | completed_pid, completed_cid, completed_end = completed
|
|---|
| 684 | if n_reviews > len(completed_pid):
|
|---|
| 685 | print(f" only {len(completed_pid):,} completed projects: writing that many reviews")
|
|---|
| 686 | n_reviews = len(completed_pid)
|
|---|
| 687 | chosen = sorted(random.sample(range(len(completed_pid)), n_reviews))
|
|---|
| 688 | ticket_id = 0
|
|---|
| 689 | print(f" dot = {PROGRESS_EVERY:,} reviews: ", end="", flush=True)
|
|---|
| 690 | with (
|
|---|
| 691 | csv_file(out_dir, "review.csv", REVIEW_COLUMNS) as reviews,
|
|---|
| 692 | csv_file(out_dir, "review_score.csv", ["review_id", "dimension_id", "score_value"]) as scores,
|
|---|
| 693 | csv_file(out_dir, "dispute_ticket.csv", DISPUTE_COLUMNS) as disputes,
|
|---|
| 694 | ):
|
|---|
| 695 | for review_id, index in enumerate(chosen, start=1):
|
|---|
| 696 | project_id, contract_id = completed_pid[index], completed_cid[index]
|
|---|
| 697 | client_id, vendor_id = contract_client[contract_id - 1], contract_vendor[contract_id - 1]
|
|---|
| 698 | end_date = date.fromordinal(completed_end[index])
|
|---|
| 699 | review_date = end_date + timedelta(days=random.randint(1, min(45, (REFERENCE_DATE - end_date).days)))
|
|---|
| 700 | created = ts_on(review_date)
|
|---|
| 701 |
|
|---|
| 702 | # the dispute is decided first, because it decides whether the review is published;
|
|---|
| 703 | # seed disputes are filed at least one day after the review
|
|---|
| 704 | days_left = (REFERENCE_DATE - review_date).days
|
|---|
| 705 | disputed = days_left >= 1 and random.random() < DISPUTE_RATE
|
|---|
| 706 | resolved_at = None
|
|---|
| 707 | if disputed:
|
|---|
| 708 | filed_at = review_date + timedelta(days=random.randint(1, min(30, days_left)))
|
|---|
| 709 | resolved_day = filed_at + timedelta(days=random.randint(1, 120))
|
|---|
| 710 | if resolved_day <= REFERENCE_DATE and random.random() < 0.85:
|
|---|
| 711 | resolved_at = ts_on(resolved_day)
|
|---|
| 712 | is_published = (not disputed or resolved_at is not None) and random.random() < 0.70
|
|---|
| 713 | updated = ts_after(max(created, resolved_at or created), 14) if is_published else created
|
|---|
| 714 | author_id = random.choice(client_to_users[client_id - 1])
|
|---|
| 715 | reviews.writerow([review_id, project_id, author_id, review_date,
|
|---|
| 716 | fake.paragraph(nb_sentences=3), is_published, created, updated])
|
|---|
| 717 |
|
|---|
| 718 | quality = vendor_quality[vendor_id - 1]
|
|---|
| 719 | for dimension_id, _, _, bias in DIMENSIONS:
|
|---|
| 720 | score = round(min(5.0, max(1.0, random.gauss(quality + bias, 0.8))))
|
|---|
| 721 | scores.writerow([review_id, dimension_id, score])
|
|---|
| 722 |
|
|---|
| 723 | if disputed:
|
|---|
| 724 | ticket_id += 1
|
|---|
| 725 | resolved = resolved_at is not None
|
|---|
| 726 | filed = ts_on(filed_at)
|
|---|
| 727 | disputes.writerow([
|
|---|
| 728 | ticket_id, random.choice(mgmt_user_ids) if resolved else None, review_id,
|
|---|
| 729 | random.choice(vendor_to_users[vendor_id - 1]), fake.paragraph(nb_sentences=2), resolved,
|
|---|
| 730 | filed_at, resolved_at, fake.sentence() if resolved else None, filed, resolved_at or filed,
|
|---|
| 731 | ])
|
|---|
| 732 | if review_id % PROGRESS_EVERY == 0:
|
|---|
| 733 | print(".", end="", flush=True)
|
|---|
| 734 | print()
|
|---|
| 735 | report("review.csv", n_reviews, t0)
|
|---|
| 736 | report("review_score.csv", n_reviews * len(DIMENSIONS), t0)
|
|---|
| 737 | report("dispute_ticket.csv", ticket_id, t0)
|
|---|
| 738 |
|
|---|
| 739 |
|
|---|
| 740 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 741 | # MAIN
|
|---|
| 742 | # ═════════════════════════════════════════════════════════════════════════════
|
|---|
| 743 | def parse_args() -> argparse.Namespace:
|
|---|
| 744 | parser = argparse.ArgumentParser(description="Generate the VQEMS seed data as CSV files for load_seed.sql.")
|
|---|
| 745 | parser.add_argument("out_dir", nargs="?", default="seed_data", type=Path,
|
|---|
| 746 | help="directory for the CSV files (default: seed_data)")
|
|---|
| 747 | parser.add_argument("--scale", type=positive_float, default=1.0,
|
|---|
| 748 | help="multiplies every table size; lookup tables are fixed (default: 1.0)")
|
|---|
| 749 | parser.add_argument("--seed", type=int, default=42, help="random seed (default: 42)")
|
|---|
| 750 | return parser.parse_args()
|
|---|
| 751 |
|
|---|
| 752 |
|
|---|
| 753 | def main() -> None:
|
|---|
| 754 | args = parse_args()
|
|---|
| 755 | Faker.seed(args.seed)
|
|---|
| 756 | random.seed(args.seed)
|
|---|
| 757 | out_dir: Path = args.out_dir
|
|---|
| 758 | out_dir.mkdir(parents=True, exist_ok=True)
|
|---|
| 759 |
|
|---|
| 760 | n_vendors = scaled(NUM_VENDORS, args.scale)
|
|---|
| 761 | n_clients = scaled(NUM_CLIENTS, args.scale)
|
|---|
| 762 | n_client_users = scaled(NUM_CLIENT_USERS, args.scale)
|
|---|
| 763 | n_vendor_users = scaled(NUM_VENDOR_USERS, args.scale)
|
|---|
| 764 | n_mgmt_users = scaled(NUM_MGMT_USERS, args.scale)
|
|---|
| 765 | n_contracts = scaled(NUM_CONTRACTS, args.scale)
|
|---|
| 766 | n_projects = scaled(NUM_PROJECTS, args.scale)
|
|---|
| 767 | n_reviews = scaled(NUM_REVIEWS, args.scale)
|
|---|
| 768 | print(f"Seed {args.seed}, scale {args.scale:g}, reference date {REFERENCE_DATE}: "
|
|---|
| 769 | f"{n_vendors:,} vendors, {n_clients:,} clients, {n_client_users + n_vendor_users + n_mgmt_users:,} users, "
|
|---|
| 770 | f"{n_contracts:,} contracts, {n_projects:,} projects, {n_reviews:,} reviews")
|
|---|
| 771 |
|
|---|
| 772 | print("\n── 1. Lookup tables ──────────────────────────────────────────────────")
|
|---|
| 773 | write_lookup_tables(out_dir)
|
|---|
| 774 |
|
|---|
| 775 | print("\n── 2. Vendors & Clients ──────────────────────────────────────────────")
|
|---|
| 776 | vendor_domains, vendor_quality = write_vendors(out_dir, n_vendors)
|
|---|
| 777 | client_domains = write_clients(out_dir, n_clients)
|
|---|
| 778 |
|
|---|
| 779 | print("\n── 3. Users ──────────────────────────────────────────────────────────")
|
|---|
| 780 | client_to_users, vendor_to_users, mgmt_user_ids = write_users(
|
|---|
| 781 | out_dir, n_client_users, n_vendor_users, n_mgmt_users, client_domains, vendor_domains
|
|---|
| 782 | )
|
|---|
| 783 |
|
|---|
| 784 | print("\n── 4. Vendor subscriptions ───────────────────────────────────────────")
|
|---|
| 785 | write_subscriptions(out_dir, n_vendors)
|
|---|
| 786 |
|
|---|
| 787 | print("\n── 5. Contracts & Projects ───────────────────────────────────────────")
|
|---|
| 788 | contract_client, contract_vendor, completed = write_contracts_and_projects(
|
|---|
| 789 | out_dir, n_contracts, n_projects, n_clients, n_vendors, vendor_to_users, mgmt_user_ids
|
|---|
| 790 | )
|
|---|
| 791 |
|
|---|
| 792 | print("\n── 6. Reviews, scores & disputes ─────────────────────────────────────")
|
|---|
| 793 | write_reviews(
|
|---|
| 794 | out_dir, n_reviews, completed, contract_client, contract_vendor,
|
|---|
| 795 | client_to_users, vendor_to_users, mgmt_user_ids, vendor_quality,
|
|---|
| 796 | )
|
|---|
| 797 |
|
|---|
| 798 | print(f"\n All CSV files written to: {out_dir.resolve()}")
|
|---|
| 799 |
|
|---|
| 800 |
|
|---|
| 801 | if __name__ == "__main__":
|
|---|
| 802 | main()
|
|---|