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>
This commit is contained in:
283
scripts/import-market-data.ts
Normal file
283
scripts/import-market-data.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user