DatabaseCreation: seed_generator.py

File seed_generator.py, 37.4 KB (added by 235013, 11 days ago)
Line 
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
10Usage: uv run seed_generator.py [out_dir] [--scale FLOAT] [--seed INT]
11
12The output is deterministic: every date is derived from REFERENCE_DATE instead of
13the clock, and both random and Faker are seeded, so the same seed, scale and code
14always produce the same files.
15"""
16
17import argparse
18import csv
19import hashlib
20import math
21import random
22import re
23import time
24from array import array
25from contextlib import contextmanager
26from datetime import date, datetime, timedelta
27from pathlib import Path
28
29from faker import Faker
30
31# ── CONFIG ────────────────────────────────────────────────────────────────────
32REFERENCE_DATE = date(2026, 5, 13) # fixed "today" for the generated data, for reproducible dates
33REFERENCE_END = datetime.combine(REFERENCE_DATE, datetime.max.time()).replace(microsecond=0)
34HISTORY_DAYS = 4 * 365 # maximum lookback for generated start dates
35PROGRESS_EVERY = 100_000
36
37fake = 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.
44NUM_VENDORS = 5_000
45NUM_CLIENTS = 20_000
46NUM_CLIENT_USERS = 50_000
47NUM_VENDOR_USERS = 25_000
48NUM_MGMT_USERS = 2_000
49NUM_CONTRACTS = 250_000
50NUM_PROJECTS = 1_400_000
51NUM_REVIEWS = 1_000_000
52
53# Probability that a review dated before REFERENCE_DATE gets a dispute.
54DISPUTE_RATE = 0.15
55
56
57# ── HELPERS ───────────────────────────────────────────────────────────────────
58def scaled(n: int, scale: float) -> int:
59 return max(1, round(n * scale))
60
61
62def 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
70def 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
77def report(filename: str, rows: int, t0: float) -> None:
78 print(f" ✓ {filename:<48} {rows:>12,} rows ({time.time() - t0:.1f}s)")
79
80
81def 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
88def day_between(start: date, end: date) -> date:
89 return start + timedelta(days=random.randint(0, (end - start).days))
90
91
92def 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
97def ts_between(start: datetime, end: datetime) -> datetime:
98 return start + timedelta(seconds=random.randint(0, int((end - start).total_seconds())))
99
100
101def 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
106def 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
114def money(value: float) -> str:
115 return f"{value:.2f}"
116
117
118def slug(text: str) -> str:
119 return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
120
121
122def password_hash() -> str:
123 return hashlib.sha256(fake.password().encode()).hexdigest()
124
125
126# ═════════════════════════════════════════════════════════════════════════════
127# 1. LOOKUP TABLES
128# ═════════════════════════════════════════════════════════════════════════════
129ROLES = [
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]
136ROLE_IDS = [r[0] for r in ROLES]
137ROLE_WEIGHTS = [5, 40, 15, 25, 15] # relative sampling weights, in ROLES order
138
139PERMISSIONS = [
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
157ROLE_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
175INDUSTRIES = [
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
198STATUSES = ["Draft", "In Progress", "On Hold", "Completed", "Cancelled", "Under Review"]
199STATUS_ID = {name: i for i, name in enumerate(STATUSES, start=1)}
200FINISHED = {"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.
206OUTCOMES = ["Completed", "Cancelled", "In Progress", "On Hold", "Under Review", "Draft"]
207OUTCOME_WEIGHTS = [72, 5, 13, 4, 3, 3]
208PROJECT_DAYS = {"Completed": (30, 730), "Cancelled": (7, 365)} # min/max duration of a finished project
209
210STATUS_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
244TECHNOLOGIES = [
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]
276TECH_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.
281DIMENSIONS = [
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
294TIERS = [
295 (1, "Starter", 99.00, False),
296 (2, "Professional", 299.00, False),
297 (3, "Business", None, True),
298 (4, "Enterprise", None, True),
299]
300TIER_IDS = [t[0] for t in TIERS]
301CUSTOM_PRICE_RANGE = {3: (350, 1_500), 4: (1_500, 6_000)}
302SUBSCRIPTION_DAYS = [30, 90, 180, 365]
303
304CURRENCIES = ["USD", "EUR", "GBP", "CHF", "CAD", "AUD"]
305CURRENCY_WEIGHTS = [40, 35, 10, 5, 5, 5]
306TLDS = ["com", "io", "net", "co", "dev"]
307
308
309def 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# ═════════════════════════════════════════════════════════════════════════════
331def 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
347def 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# ═════════════════════════════════════════════════════════════════════════════
365USER_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
371def 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# ═════════════════════════════════════════════════════════════════════════════
441SUBSCRIPTION_COLUMNS = [
442 "contract_id", "vendor_id", "tier_id", "negotiated_price", "start_date", "end_date",
443 "is_active", "created_at", "updated_at",
444]
445
446
447def 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# ═════════════════════════════════════════════════════════════════════════════
478CONTRACT_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]
482PROJECT_COLUMNS = [
483 "project_id", "contract_id", "status_id", "project_name", "start_date", "end_date", "budget",
484 "created_at", "updated_at",
485]
486AUDIT_COLUMNS = ["audit_id", "project_id", "old_budget", "new_budget", "created_at", "updated_at"]
487HISTORY_COLUMNS = [
488 "project_status_history_id", "project_id", "vendor_user_id", "management_user_id", "status_id",
489 "changed_at", "comment",
490]
491
492
493def 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
512def 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
517def 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
523def 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# ═════════════════════════════════════════════════════════════════════════════
657REVIEW_COLUMNS = [
658 "review_id", "project_id", "client_user_id", "review_date", "summary_text", "is_published",
659 "created_at", "updated_at",
660]
661DISPUTE_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
667def 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# ═════════════════════════════════════════════════════════════════════════════
743def 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
753def 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
801if __name__ == "__main__":
802 main()