Files
goodgo-platform/scripts/import-market-data.ts
Ho Ngoc Hai 51c6eed565 feat(seed): add standalone seed scripts for districts, plans, and market data
- scripts/seed-districts.ts: Vietnam district/ward data for HCM, Hanoi, Da Nang with sample properties
- scripts/seed-plans.ts: Subscription plans (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)
- scripts/import-market-data.ts: Market index data across all 3 cities with realistic pricing
- All scripts are idempotent (upsert/ON CONFLICT DO NOTHING)
- Refactored prisma/seed.ts to import shared data from scripts, removing duplication

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 05:10:05 +07:00

284 lines
6.8 KiB
TypeScript

/**
* 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 { PrismaClient, PropertyType } from '@prisma/client';
const prisma = new PrismaClient();
// =============================================================================
// Market data configuration — avg price per m2 (VND) by city/district
// =============================================================================
interface DistrictPricing {
district: string;
avgPriceM2: Record<string, number>; // keyed by PropertyType
}
const MARKET_DATA: Record<string, DistrictPricing[]> = {
'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'];
function randomVariation(base: number, pct: number): number {
return base * (1 + (Math.random() - 0.5) * 2 * pct);
}
async function importMarketData() {
console.log('Importing market data for HCM, Hanoi, Da Nang...\n');
let total = 0;
for (const [city, districts] of Object.entries(MARKET_DATA)) {
console.log(` ${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.log(` ${cityCount} market index records`);
}
console.log(`\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 };