Files
goodgo-platform/tmp/tec2773-industrial-stash/scripts/seed-industrial-listings-synth.ts

649 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<T>(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, ParkRef[]> = {
[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<StratKey & { count: number }> = [
// 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, FeatureBounds> = {
[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<readonly [IndustrialLeaseType, number]>
> = {
[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<readonly [IndustrialListingStatus, number]> = [
[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<void> {
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<void> {
try {
await seedIndustrialListingsSynth();
} catch (err) {
console.error('Synthetic seed error:', err);
process.exit(1);
}
}
if (require.main === module) {
void main();
}