/** * Import sample market data for development. * * Generates MarketIndex records across HCM, Hanoi, and Da Nang * with realistic pricing for Vietnamese real estate. * * Usage: npx tsx scripts/import-market-data.ts * Idempotent: uses upsert on compound unique constraint. */ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient, type PropertyType } from '@prisma/client'; import pg from 'pg'; const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); // ============================================================================= // Market data configuration — avg price per m2 (VND) by city/district // ============================================================================= interface DistrictPricing { district: string; avgPriceM2: Record; // keyed by PropertyType } const MARKET_DATA: Record = { 'Hồ Chí Minh': [ { district: 'Quận 1', avgPriceM2: { APARTMENT: 120_000_000, TOWNHOUSE: 250_000_000, OFFICE: 80_000_000, SHOPHOUSE: 300_000_000, }, }, { district: 'Quận 3', avgPriceM2: { APARTMENT: 90_000_000, TOWNHOUSE: 180_000_000, OFFICE: 60_000_000, }, }, { district: 'Quận 7', avgPriceM2: { APARTMENT: 65_000_000, TOWNHOUSE: 130_000_000, LAND: 85_000_000, }, }, { district: 'Thủ Đức', avgPriceM2: { APARTMENT: 55_000_000, TOWNHOUSE: 100_000_000, LAND: 60_000_000, VILLA: 120_000_000, }, }, { district: 'Quận Bình Thạnh', avgPriceM2: { APARTMENT: 70_000_000, TOWNHOUSE: 140_000_000, }, }, { district: 'Quận Phú Nhuận', avgPriceM2: { APARTMENT: 80_000_000, TOWNHOUSE: 160_000_000, }, }, { district: 'Quận Tân Bình', avgPriceM2: { APARTMENT: 50_000_000, TOWNHOUSE: 110_000_000, LAND: 55_000_000, }, }, { district: 'Quận Gò Vấp', avgPriceM2: { APARTMENT: 45_000_000, TOWNHOUSE: 90_000_000, LAND: 50_000_000, }, }, ], 'Hà Nội': [ { district: 'Hoàn Kiếm', avgPriceM2: { APARTMENT: 110_000_000, TOWNHOUSE: 350_000_000, SHOPHOUSE: 400_000_000, }, }, { district: 'Ba Đình', avgPriceM2: { APARTMENT: 95_000_000, TOWNHOUSE: 220_000_000, VILLA: 200_000_000, }, }, { district: 'Đống Đa', avgPriceM2: { APARTMENT: 75_000_000, TOWNHOUSE: 180_000_000, }, }, { district: 'Hai Bà Trưng', avgPriceM2: { APARTMENT: 70_000_000, TOWNHOUSE: 170_000_000, }, }, { district: 'Cầu Giấy', avgPriceM2: { APARTMENT: 65_000_000, TOWNHOUSE: 150_000_000, OFFICE: 55_000_000, }, }, { district: 'Tây Hồ', avgPriceM2: { APARTMENT: 80_000_000, VILLA: 180_000_000, TOWNHOUSE: 160_000_000, }, }, { district: 'Nam Từ Liêm', avgPriceM2: { APARTMENT: 55_000_000, TOWNHOUSE: 120_000_000, LAND: 65_000_000, }, }, ], 'Đà Nẵng': [ { district: 'Hải Châu', avgPriceM2: { APARTMENT: 45_000_000, TOWNHOUSE: 100_000_000, SHOPHOUSE: 120_000_000, }, }, { district: 'Thanh Khê', avgPriceM2: { APARTMENT: 35_000_000, TOWNHOUSE: 70_000_000, LAND: 40_000_000, }, }, { district: 'Sơn Trà', avgPriceM2: { APARTMENT: 40_000_000, TOWNHOUSE: 80_000_000, VILLA: 95_000_000, }, }, { district: 'Ngũ Hành Sơn', avgPriceM2: { APARTMENT: 42_000_000, VILLA: 110_000_000, LAND: 55_000_000, }, }, { district: 'Liên Chiểu', avgPriceM2: { APARTMENT: 28_000_000, TOWNHOUSE: 55_000_000, LAND: 30_000_000, }, }, { district: 'Cẩm Lệ', avgPriceM2: { APARTMENT: 30_000_000, TOWNHOUSE: 60_000_000, LAND: 35_000_000, }, }, ], }; const PERIODS = ['2025-Q3', '2025-Q4', '2026-Q1', '2026-Q2']; function randomVariation(base: number, pct: number): number { return base * (1 + (Math.random() - 0.5) * 2 * pct); } async function importMarketData() { console.warn('Importing market data for HCM, Hanoi, Da Nang...\n'); let total = 0; for (const [city, districts] of Object.entries(MARKET_DATA)) { console.warn(` ${city}:`); let cityCount = 0; for (const { district, avgPriceM2 } of districts) { for (const [propTypeKey, basePrice] of Object.entries(avgPriceM2)) { const propertyType = propTypeKey as PropertyType; for (const period of PERIODS) { const variation = randomVariation(basePrice, 0.08); const medianArea = propertyType === 'LAND' ? 100 : propertyType === 'VILLA' ? 200 : 75; const totalListings = Math.floor(50 + Math.random() * 400); const daysOnMarket = 20 + Math.random() * 70; // YoY change: slight uptrend with noise const periodIdx = PERIODS.indexOf(period); const trendPct = (periodIdx - 1) * 2; // negative for older, positive for newer const yoyChange = trendPct + (Math.random() - 0.5) * 10; await prisma.marketIndex.upsert({ where: { district_city_propertyType_period: { district, city, propertyType, period, }, }, update: { avgPriceM2: variation, medianPrice: BigInt(Math.round(variation * medianArea)), totalListings, daysOnMarket, inventoryLevel: Math.floor(totalListings * 0.6), absorptionRate: 0.2 + Math.random() * 0.5, yoyChange, }, create: { district, city, propertyType, period, avgPriceM2: variation, medianPrice: BigInt(Math.round(variation * medianArea)), totalListings, daysOnMarket, inventoryLevel: Math.floor(totalListings * 0.6), absorptionRate: 0.2 + Math.random() * 0.5, yoyChange, }, }); cityCount++; total++; } } } console.warn(` ${cityCount} market index records`); } console.warn(`\nTotal: ${total} market index records imported.`); } if (require.main === module) { importMarketData() .catch((e) => { console.error('Import market data failed:', e); process.exit(1); }) .finally(() => prisma.$disconnect()); } export { importMarketData, MARKET_DATA, PERIODS };