feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user