feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -0,0 +1,648 @@
/**
* 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();
}