- 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>
284 lines
6.8 KiB
TypeScript
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 };
|