649 lines
25 KiB
TypeScript
649 lines
25 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|