/** * Synthetic industrial listing seed (TEC-2769 — R5.2.2). * * Generates a distribution-matched labelled dataset of industrial listings for * listing-level AVM training. Riding on top of the 12 hand-curated rows in * `seed-industrial-listings.ts`, this script produces ~600 additional rows * stratified across region × propertyType × leaseType × park. * * ## Provenance / licensing * * - All rows are SYNTHETIC. No rows are scraped, copied, or derived from any * third-party listings platform or transaction feed. * - No PII. Sellers and agents reuse existing seeded profiles * (`seed-seller-001`, `seed-seller-002`, `seed-agentprofile-00{1,2,3}`). * - Generator is deterministic given `SYNTH_SEED` (default `2026`). Re-running * with the same seed reproduces the same rows via upsert-on-id. * * ## Usage * * # As part of `pnpm db:seed` (automatic) * pnpm db:seed * * # Standalone * npx tsx scripts/seed-industrial-listings-synth.ts * * # Override generator seed * SYNTH_SEED=42 npx tsx scripts/seed-industrial-listings-synth.ts * * Idempotent: upserts by id (`synth-ind-listing-0001`..`synth-ind-listing-0600`). */ import { PrismaPg } from '@prisma/adapter-pg'; import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType, PrismaClient, VietnamRegion, } from '@prisma/client'; import pg from 'pg'; // --------------------------------------------------------------------------- // Deterministic RNG (mulberry32) — same seed → same dataset. // --------------------------------------------------------------------------- function mulberry32(seedNumber: number): () => number { let state = seedNumber >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const DEFAULT_SEED = 2026; const SEED = Number(process.env['SYNTH_SEED'] ?? DEFAULT_SEED); const rng = mulberry32(SEED); function rand(): number { return rng(); } function randRange(min: number, max: number): number { return min + rand() * (max - min); } function randInt(min: number, max: number): number { return Math.floor(randRange(min, max + 1)); } function pickWeighted(items: readonly (readonly [T, number])[]): T { const total = items.reduce((s, [, w]) => s + w, 0); let r = rand() * total; for (const [item, w] of items) { r -= w; if (r <= 0) return item; } return items[items.length - 1]![0]; } function roundTo(value: number, decimals: number): number { const f = 10 ** decimals; return Math.round(value * f) / f; } // --------------------------------------------------------------------------- // Reference data: the 20 seeded parks, with minimal context the generator // needs. Must stay in sync with `scripts/seed-industrial-parks.ts`. // --------------------------------------------------------------------------- interface ParkRef { id: string; region: VietnamRegion; landRentUsdM2Year: number | null; rbfRentUsdM2Month: number | null; rbwRentUsdM2Month: number | null; managementFeeUsd: number | null; } const PARKS: ParkRef[] = [ { id: 'seed-kcn-001', region: VietnamRegion.NORTH, landRentUsdM2Year: 90, rbfRentUsdM2Month: 5.5, rbwRentUsdM2Month: 4.8, managementFeeUsd: 0.7 }, { id: 'seed-kcn-002', region: VietnamRegion.SOUTH, landRentUsdM2Year: 180, rbfRentUsdM2Month: 6.5, rbwRentUsdM2Month: 5.8, managementFeeUsd: 0.8 }, { id: 'seed-kcn-003', region: VietnamRegion.SOUTH, landRentUsdM2Year: 130, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.65 }, { id: 'seed-kcn-004', region: VietnamRegion.SOUTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 }, { id: 'seed-kcn-005', region: VietnamRegion.NORTH, landRentUsdM2Year: 100, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.6 }, { id: 'seed-kcn-006', region: VietnamRegion.SOUTH, landRentUsdM2Year: 140, rbfRentUsdM2Month: 5.0, rbwRentUsdM2Month: 4.5, managementFeeUsd: 0.5 }, { id: 'seed-kcn-007', region: VietnamRegion.SOUTH, landRentUsdM2Year: 150, rbfRentUsdM2Month: 5.5, rbwRentUsdM2Month: 4.8, managementFeeUsd: 0.7 }, { id: 'seed-kcn-008', region: VietnamRegion.NORTH, landRentUsdM2Year: 85, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.55 }, { id: 'seed-kcn-009', region: VietnamRegion.SOUTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.3, rbwRentUsdM2Month: 3.9, managementFeeUsd: 0.55 }, { id: 'seed-kcn-010', region: VietnamRegion.SOUTH, landRentUsdM2Year: 120, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.6 }, { id: 'seed-kcn-011', region: VietnamRegion.NORTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 }, { id: 'seed-kcn-012', region: VietnamRegion.NORTH, landRentUsdM2Year: 85, rbfRentUsdM2Month: 4.3, rbwRentUsdM2Month: 3.8, managementFeeUsd: 0.6 }, { id: 'seed-kcn-013', region: VietnamRegion.SOUTH, landRentUsdM2Year: 100, rbfRentUsdM2Month: 4.2, rbwRentUsdM2Month: 3.7, managementFeeUsd: 0.5 }, { id: 'seed-kcn-014', region: VietnamRegion.SOUTH, landRentUsdM2Year: 80, rbfRentUsdM2Month: 4.0, rbwRentUsdM2Month: 3.6, managementFeeUsd: 0.5 }, { id: 'seed-kcn-015', region: VietnamRegion.NORTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 5.0, rbwRentUsdM2Month: 4.5, managementFeeUsd: 0.65 }, { id: 'seed-kcn-016', region: VietnamRegion.NORTH, landRentUsdM2Year: 105, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 }, { id: 'seed-kcn-017', region: VietnamRegion.SOUTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.55 }, { id: 'seed-kcn-018', region: VietnamRegion.SOUTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.55 }, { id: 'seed-kcn-019', region: VietnamRegion.CENTRAL, landRentUsdM2Year: 60, rbfRentUsdM2Month: 3.8, rbwRentUsdM2Month: 3.4, managementFeeUsd: 0.4 }, { id: 'seed-kcn-020', region: VietnamRegion.CENTRAL, landRentUsdM2Year: 40, rbfRentUsdM2Month: 3.5, rbwRentUsdM2Month: 3.2, managementFeeUsd: 0.35 }, ]; const PARKS_BY_REGION: Record = { [VietnamRegion.NORTH]: PARKS.filter((p) => p.region === VietnamRegion.NORTH), [VietnamRegion.SOUTH]: PARKS.filter((p) => p.region === VietnamRegion.SOUTH), [VietnamRegion.CENTRAL]: PARKS.filter((p) => p.region === VietnamRegion.CENTRAL), }; // --------------------------------------------------------------------------- // Stratification table: region × propertyType → row count. // Totals to 600 synthetic rows. // --------------------------------------------------------------------------- type StratKey = { region: VietnamRegion; propertyType: IndustrialPropertyType }; const STRATIFICATION: ReadonlyArray = [ // INDUSTRIAL_LAND { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 40 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 55 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 15 }, // READY_BUILT_FACTORY { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 65 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 90 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 15 }, // READY_BUILT_WAREHOUSE { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 55 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 80 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 15 }, // LOGISTICS_CENTER { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 30 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 45 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 10 }, // OFFICE_IN_PARK { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 25 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 35 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 10 }, // DATA_CENTER { region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.DATA_CENTER, count: 10 }, { region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.DATA_CENTER, count: 15 }, { region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.DATA_CENTER, count: 5 }, ]; const TOTAL_EXPECTED = STRATIFICATION.reduce((s, x) => s + x.count, 0); // --------------------------------------------------------------------------- // Feature bounds per propertyType. // --------------------------------------------------------------------------- interface FeatureBounds { areaM2: [number, number]; ceilingHeightM: [number, number] | null; floorLoadTonM2: [number, number] | null; columnSpacingM: [number, number] | null; dockCount: [number, number] | null; craneChance: number; craneCapacityTon: [number, number] | null; mezzanineChance: number; officeChance: number; officeFraction: [number, number]; // fraction of areaM2 used as office powerCapacityKva: [number, number] | null; waterSupplyM3Day: [number, number] | null; pricingMode: 'land' | 'rbf' | 'rbw' | 'office' | 'data_center'; priceBias: number; // multiplier applied to park-anchored price } const BOUNDS: Record = { [IndustrialPropertyType.INDUSTRIAL_LAND]: { areaM2: [2_000, 50_000], ceilingHeightM: null, floorLoadTonM2: null, columnSpacingM: null, dockCount: null, craneChance: 0, craneCapacityTon: null, mezzanineChance: 0, officeChance: 0, officeFraction: [0, 0], powerCapacityKva: null, waterSupplyM3Day: null, pricingMode: 'land', priceBias: 1.0, }, [IndustrialPropertyType.READY_BUILT_FACTORY]: { areaM2: [800, 12_000], ceilingHeightM: [8, 14], floorLoadTonM2: [2, 5], columnSpacingM: [10, 20], dockCount: [2, 8], craneChance: 0.4, craneCapacityTon: [3, 15], mezzanineChance: 0.45, officeChance: 0.8, officeFraction: [0.03, 0.1], powerCapacityKva: [200, 2_000], waterSupplyM3Day: [20, 120], pricingMode: 'rbf', priceBias: 1.0, }, [IndustrialPropertyType.READY_BUILT_WAREHOUSE]: { areaM2: [500, 20_000], ceilingHeightM: [9, 14], floorLoadTonM2: [3, 6], columnSpacingM: [10, 20], dockCount: [2, 12], craneChance: 0.15, craneCapacityTon: [3, 10], mezzanineChance: 0.2, officeChance: 0.6, officeFraction: [0.02, 0.06], powerCapacityKva: [100, 600], waterSupplyM3Day: [10, 80], pricingMode: 'rbw', priceBias: 1.0, }, [IndustrialPropertyType.LOGISTICS_CENTER]: { areaM2: [4_000, 30_000], ceilingHeightM: [10, 14], floorLoadTonM2: [3, 6], columnSpacingM: [12, 22], dockCount: [6, 20], craneChance: 0.05, craneCapacityTon: [3, 8], mezzanineChance: 0.1, officeChance: 0.7, officeFraction: [0.02, 0.05], powerCapacityKva: [300, 1_500], waterSupplyM3Day: [30, 120], pricingMode: 'rbw', priceBias: 0.95, }, [IndustrialPropertyType.OFFICE_IN_PARK]: { areaM2: [200, 1_500], ceilingHeightM: [3, 4.5], floorLoadTonM2: null, columnSpacingM: null, dockCount: null, craneChance: 0, craneCapacityTon: null, mezzanineChance: 0, officeChance: 1, officeFraction: [1, 1], powerCapacityKva: [60, 200], waterSupplyM3Day: [3, 10], pricingMode: 'office', priceBias: 1.5, }, [IndustrialPropertyType.DATA_CENTER]: { areaM2: [1_500, 10_000], ceilingHeightM: [4, 6], floorLoadTonM2: [5, 10], columnSpacingM: [9, 15], dockCount: [0, 2], craneChance: 0.05, craneCapacityTon: [2, 5], mezzanineChance: 0.1, officeChance: 0.9, officeFraction: [0.05, 0.15], powerCapacityKva: [1_500, 8_000], waterSupplyM3Day: [40, 200], pricingMode: 'rbf', priceBias: 1.3, }, }; // --------------------------------------------------------------------------- // leaseType distribution per propertyType (sublease bias applied to price). // --------------------------------------------------------------------------- const LEASE_TYPE_PROBS: Record< IndustrialPropertyType, ReadonlyArray > = { [IndustrialPropertyType.INDUSTRIAL_LAND]: [ [IndustrialLeaseType.LAND_LEASE, 1.0], ], [IndustrialPropertyType.READY_BUILT_FACTORY]: [ [IndustrialLeaseType.FACTORY_LEASE, 0.9], [IndustrialLeaseType.SUBLEASE, 0.1], ], [IndustrialPropertyType.READY_BUILT_WAREHOUSE]: [ [IndustrialLeaseType.WAREHOUSE_LEASE, 0.85], [IndustrialLeaseType.SUBLEASE, 0.15], ], [IndustrialPropertyType.LOGISTICS_CENTER]: [ [IndustrialLeaseType.WAREHOUSE_LEASE, 1.0], ], [IndustrialPropertyType.OFFICE_IN_PARK]: [ [IndustrialLeaseType.FACTORY_LEASE, 1.0], ], [IndustrialPropertyType.DATA_CENTER]: [ [IndustrialLeaseType.FACTORY_LEASE, 0.6], [IndustrialLeaseType.SUBLEASE, 0.4], ], }; const STATUS_PROBS: ReadonlyArray = [ [IndustrialListingStatus.ACTIVE, 0.8], [IndustrialListingStatus.DRAFT, 0.1], [IndustrialListingStatus.RESERVED, 0.05], [IndustrialListingStatus.LEASED, 0.05], ]; const SELLERS = ['seed-seller-001', 'seed-seller-002']; const AGENTS = [ 'seed-agentprofile-001', 'seed-agentprofile-002', 'seed-agentprofile-003', null, ]; // --------------------------------------------------------------------------- // Title / description templates (Vietnamese). Deliberately generic, no PII. // --------------------------------------------------------------------------- function titleFor(t: IndustrialPropertyType, areaM2: number, parkId: string): string { const kcnShort = parkId.replace('seed-kcn-', 'KCN#'); const m2 = Math.round(areaM2); switch (t) { case IndustrialPropertyType.INDUSTRIAL_LAND: return `Đất công nghiệp ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; case IndustrialPropertyType.READY_BUILT_FACTORY: return `Nhà xưởng xây sẵn ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; case IndustrialPropertyType.READY_BUILT_WAREHOUSE: return `Kho xưởng xây sẵn ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; case IndustrialPropertyType.LOGISTICS_CENTER: return `Trung tâm logistics ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; case IndustrialPropertyType.OFFICE_IN_PARK: return `Văn phòng trong KCN ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; case IndustrialPropertyType.DATA_CENTER: return `Data center ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`; } } function descriptionFor( t: IndustrialPropertyType, areaM2: number, region: VietnamRegion, ): string { const regionLabel = region === VietnamRegion.NORTH ? 'miền Bắc' : region === VietnamRegion.SOUTH ? 'miền Nam' : 'miền Trung'; const m2 = Math.round(areaM2).toLocaleString('vi-VN'); switch (t) { case IndustrialPropertyType.INDUSTRIAL_LAND: return `Lô đất công nghiệp ${m2}m² tại ${regionLabel}. Hạ tầng KCN hoàn chỉnh, pháp lý rõ ràng, phù hợp xây dựng nhà máy.`; case IndustrialPropertyType.READY_BUILT_FACTORY: return `Nhà xưởng xây sẵn ${m2}m² tại ${regionLabel}. Kết cấu thép tiền chế, nền bê tông chịu tải, hệ thống PCCC, điện 3 pha, nước và xử lý nước thải đầy đủ.`; case IndustrialPropertyType.READY_BUILT_WAREHOUSE: return `Kho xưởng xây sẵn ${m2}m² tại ${regionLabel}. Nền epoxy chịu tải, hệ thống dock container, PCCC tự động, phù hợp logistics và phân phối.`; case IndustrialPropertyType.LOGISTICS_CENTER: return `Trung tâm logistics ${m2}m² tại ${regionLabel}. Nhiều dock container, bãi xe tải, phù hợp kho ngoại quan và trung chuyển.`; case IndustrialPropertyType.OFFICE_IN_PARK: return `Văn phòng trong KCN ${m2}m² tại ${regionLabel}. Điều hòa trung tâm, thang máy, bãi đỗ xe, phù hợp văn phòng điều hành nhà máy lân cận.`; case IndustrialPropertyType.DATA_CENTER: return `Data center ${m2}m² tại ${regionLabel}. Nguồn điện dự phòng kép, hệ thống làm mát N+1, kết nối cáp quang đa nhà mạng.`; } } // --------------------------------------------------------------------------- // Row synthesis. // --------------------------------------------------------------------------- interface SynthListing { id: string; parkId: string; sellerId: string; agentId: string | null; propertyType: IndustrialPropertyType; leaseType: IndustrialLeaseType; status: IndustrialListingStatus; title: string; description: string; areaM2: number; ceilingHeightM: number | null; floorLoadTonM2: number | null; columnSpacingM: number | null; dockCount: number | null; craneCapacityTon: number | null; hasMezzanine: boolean; hasOfficeArea: boolean; officeAreaM2: number | null; priceUsdM2: number | null; pricingUnit: string | null; totalLeasePrice: number | null; managementFee: number | null; depositMonths: number | null; minLeaseYears: number | null; maxLeaseYears: number | null; availableFrom: Date | null; powerCapacityKva: number | null; waterSupplyM3Day: number | null; } function priceForRow( park: ParkRef, t: IndustrialPropertyType, leaseType: IndustrialLeaseType, ): { priceUsdM2: number; pricingUnit: string } { const b = BOUNDS[t]; const noise = randRange(0.85, 1.15); const subleaseAdj = leaseType === IndustrialLeaseType.SUBLEASE ? 0.9 : 1.0; switch (b.pricingMode) { case 'land': { const base = park.landRentUsdM2Year ?? 80; return { priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2), pricingUnit: 'usd/m2/year', }; } case 'rbf': case 'data_center': { const base = park.rbfRentUsdM2Month ?? 4.5; return { priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2), pricingUnit: 'usd/m2/month', }; } case 'rbw': { const base = park.rbwRentUsdM2Month ?? 4.0; return { priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2), pricingUnit: 'usd/m2/month', }; } case 'office': { // Office is priced off factory rent with +50% bias (per plan). const base = park.rbfRentUsdM2Month ?? 4.5; return { priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2), pricingUnit: 'usd/m2/month', }; } } } function generateOne( index: number, region: VietnamRegion, t: IndustrialPropertyType, ): SynthListing { const regionParks = PARKS_BY_REGION[region]; if (regionParks.length === 0) { throw new Error(`No parks in region ${region}`); } const park = regionParks[Math.floor(rand() * regionParks.length)]!; const b = BOUNDS[t]; const leaseType = pickWeighted(LEASE_TYPE_PROBS[t]); const status = pickWeighted(STATUS_PROBS); const areaM2 = roundTo(randRange(b.areaM2[0], b.areaM2[1]), 0); const ceilingHeightM = b.ceilingHeightM ? roundTo(randRange(b.ceilingHeightM[0], b.ceilingHeightM[1]), 1) : null; const floorLoadTonM2 = b.floorLoadTonM2 ? roundTo(randRange(b.floorLoadTonM2[0], b.floorLoadTonM2[1]), 1) : null; const columnSpacingM = b.columnSpacingM ? roundTo(randRange(b.columnSpacingM[0], b.columnSpacingM[1]), 0) : null; const dockCount = b.dockCount ? randInt(b.dockCount[0], b.dockCount[1]) : null; const hasCrane = b.craneChance > 0 && rand() < b.craneChance; const craneCapacityTon = hasCrane && b.craneCapacityTon ? roundTo(randRange(b.craneCapacityTon[0], b.craneCapacityTon[1]), 0) : null; const hasMezzanine = rand() < b.mezzanineChance; const hasOfficeArea = rand() < b.officeChance; const officeAreaM2 = hasOfficeArea && b.officeFraction[1] > 0 ? roundTo(areaM2 * randRange(b.officeFraction[0], b.officeFraction[1]), 0) : null; const powerCapacityKva = b.powerCapacityKva ? roundTo(randRange(b.powerCapacityKva[0], b.powerCapacityKva[1]), 0) : null; const waterSupplyM3Day = b.waterSupplyM3Day ? roundTo(randRange(b.waterSupplyM3Day[0], b.waterSupplyM3Day[1]), 0) : null; // Price only populated when status is not DRAFT — DRAFT rows intentionally // left null to exercise "missing label" paths in the AVM pipeline. let priceUsdM2: number | null = null; let pricingUnit: string | null = null; let totalLeasePrice: number | null = null; if (status !== IndustrialListingStatus.DRAFT) { const p = priceForRow(park, t, leaseType); priceUsdM2 = p.priceUsdM2; pricingUnit = p.pricingUnit; totalLeasePrice = roundTo(priceUsdM2 * areaM2, 0); } else { // Even DRAFT rows get a pricing unit hint so the AVM can condition on it. const p = priceForRow(park, t, leaseType); pricingUnit = p.pricingUnit; } const minLeaseYears = t === IndustrialPropertyType.INDUSTRIAL_LAND ? randInt(20, 30) : randInt(1, 5); const maxLeaseYears = t === IndustrialPropertyType.INDUSTRIAL_LAND ? randInt(40, 50) : randInt(5, 20); const availableOffsetDays = randInt(0, 180); const availableFrom = new Date(Date.now() + availableOffsetDays * 24 * 60 * 60 * 1000); const id = `synth-ind-listing-${String(index + 1).padStart(4, '0')}`; const sellerId = SELLERS[Math.floor(rand() * SELLERS.length)]!; const agentId = AGENTS[Math.floor(rand() * AGENTS.length)] ?? null; return { id, parkId: park.id, sellerId, agentId, propertyType: t, leaseType, status, title: titleFor(t, areaM2, park.id), description: descriptionFor(t, areaM2, park.region), areaM2, ceilingHeightM, floorLoadTonM2, columnSpacingM, dockCount, craneCapacityTon, hasMezzanine, hasOfficeArea, officeAreaM2, priceUsdM2, pricingUnit, totalLeasePrice, managementFee: park.managementFeeUsd, depositMonths: randInt(2, 6), minLeaseYears, maxLeaseYears, availableFrom, powerCapacityKva, waterSupplyM3Day, }; } function generateAll(): SynthListing[] { const rows: SynthListing[] = []; let i = 0; for (const bucket of STRATIFICATION) { for (let j = 0; j < bucket.count; j++) { rows.push(generateOne(i, bucket.region, bucket.propertyType)); i++; } } return rows; } // --------------------------------------------------------------------------- // DB write path. // --------------------------------------------------------------------------- export async function seedIndustrialListingsSynth(): Promise { const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); console.log(`🏭 Seeding synthetic industrial listings (seed=${SEED}, target=${TOTAL_EXPECTED})...`); const rows = generateAll(); if (rows.length !== TOTAL_EXPECTED) { throw new Error( `Generator invariant broken: produced ${rows.length} rows, expected ${TOTAL_EXPECTED}`, ); } const now = new Date(); let written = 0; try { for (const l of rows) { const isPublished = l.status === IndustrialListingStatus.ACTIVE || l.status === IndustrialListingStatus.RESERVED; await prisma.industrialListing.upsert({ where: { id: l.id }, update: { status: l.status, priceUsdM2: l.priceUsdM2, totalLeasePrice: l.totalLeasePrice, pricingUnit: l.pricingUnit, }, create: { id: l.id, parkId: l.parkId, sellerId: l.sellerId, agentId: l.agentId, propertyType: l.propertyType, leaseType: l.leaseType, status: l.status, title: l.title, description: l.description, areaM2: l.areaM2, ceilingHeightM: l.ceilingHeightM, floorLoadTonM2: l.floorLoadTonM2, columnSpacingM: l.columnSpacingM, dockCount: l.dockCount, craneCapacityTon: l.craneCapacityTon, hasMezzanine: l.hasMezzanine, hasOfficeArea: l.hasOfficeArea, officeAreaM2: l.officeAreaM2, priceUsdM2: l.priceUsdM2, pricingUnit: l.pricingUnit, totalLeasePrice: l.totalLeasePrice, managementFee: l.managementFee, depositMonths: l.depositMonths, minLeaseYears: l.minLeaseYears, maxLeaseYears: l.maxLeaseYears, availableFrom: l.availableFrom, powerCapacityKva: l.powerCapacityKva, waterSupplyM3Day: l.waterSupplyM3Day, viewCount: Math.floor(rand() * 200) + 5, inquiryCount: Math.floor(rand() * 15), publishedAt: isPublished ? now : null, }, }); written++; } console.log(`🏭 Seeded ${written} synthetic industrial listings.`); } finally { await prisma.$disconnect(); await pool.end(); } } // Standalone entry point. async function main(): Promise { try { await seedIndustrialListingsSynth(); } catch (err) { console.error('Synthetic seed error:', err); process.exit(1); } } if (require.main === module) { void main(); }