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:
478
prisma/seed.ts
478
prisma/seed.ts
@@ -1,450 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
UserRole,
|
UserRole,
|
||||||
PlanTier,
|
|
||||||
PropertyType,
|
PropertyType,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
ListingStatus,
|
ListingStatus,
|
||||||
Direction,
|
Direction,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { PLANS, seedPlans } from '../scripts/seed-plans';
|
||||||
|
import { HCM_DISTRICTS, HANOI_DISTRICTS, DANANG_DISTRICTS, CITY_COORDINATES } from '../scripts/seed-districts';
|
||||||
|
import { importMarketData } from '../scripts/import-market-data';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Districts & Wards — HCM & Hanoi
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const HCM_DISTRICTS = [
|
|
||||||
{
|
|
||||||
district: 'Quận 1',
|
|
||||||
wards: [
|
|
||||||
'Bến Nghé',
|
|
||||||
'Bến Thành',
|
|
||||||
'Cầu Kho',
|
|
||||||
'Cầu Ông Lãnh',
|
|
||||||
'Cô Giang',
|
|
||||||
'Đa Kao',
|
|
||||||
'Nguyễn Cư Trinh',
|
|
||||||
'Nguyễn Thái Bình',
|
|
||||||
'Phạm Ngũ Lão',
|
|
||||||
'Tân Định',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận 3',
|
|
||||||
wards: [
|
|
||||||
'Phường 1',
|
|
||||||
'Phường 2',
|
|
||||||
'Phường 3',
|
|
||||||
'Phường 4',
|
|
||||||
'Phường 5',
|
|
||||||
'Phường 9',
|
|
||||||
'Phường 10',
|
|
||||||
'Phường 11',
|
|
||||||
'Phường 12',
|
|
||||||
'Phường 13',
|
|
||||||
'Phường 14',
|
|
||||||
'Võ Thị Sáu',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận 7',
|
|
||||||
wards: [
|
|
||||||
'Bình Thuận',
|
|
||||||
'Phú Mỹ',
|
|
||||||
'Phú Thuận',
|
|
||||||
'Tân Hưng',
|
|
||||||
'Tân Kiểng',
|
|
||||||
'Tân Phong',
|
|
||||||
'Tân Phú',
|
|
||||||
'Tân Quy',
|
|
||||||
'Tân Thuận Đông',
|
|
||||||
'Tân Thuận Tây',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Thủ Đức',
|
|
||||||
wards: [
|
|
||||||
'An Khánh',
|
|
||||||
'An Lợi Đông',
|
|
||||||
'An Phú',
|
|
||||||
'Bình Chiểu',
|
|
||||||
'Bình Thọ',
|
|
||||||
'Bình Trưng Đông',
|
|
||||||
'Bình Trưng Tây',
|
|
||||||
'Cát Lái',
|
|
||||||
'Hiệp Bình Chánh',
|
|
||||||
'Hiệp Bình Phước',
|
|
||||||
'Linh Chiểu',
|
|
||||||
'Linh Đông',
|
|
||||||
'Linh Tây',
|
|
||||||
'Linh Trung',
|
|
||||||
'Linh Xuân',
|
|
||||||
'Long Bình',
|
|
||||||
'Long Phước',
|
|
||||||
'Long Thạnh Mỹ',
|
|
||||||
'Long Trường',
|
|
||||||
'Phú Hữu',
|
|
||||||
'Phước Bình',
|
|
||||||
'Phước Long A',
|
|
||||||
'Phước Long B',
|
|
||||||
'Tam Bình',
|
|
||||||
'Tam Phú',
|
|
||||||
'Tân Phú',
|
|
||||||
'Thạnh Mỹ Lợi',
|
|
||||||
'Thảo Điền',
|
|
||||||
'Thủ Thiêm',
|
|
||||||
'Trường Thạnh',
|
|
||||||
'Trường Thọ',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận Bình Thạnh',
|
|
||||||
wards: [
|
|
||||||
'Phường 1',
|
|
||||||
'Phường 2',
|
|
||||||
'Phường 3',
|
|
||||||
'Phường 5',
|
|
||||||
'Phường 6',
|
|
||||||
'Phường 7',
|
|
||||||
'Phường 11',
|
|
||||||
'Phường 12',
|
|
||||||
'Phường 13',
|
|
||||||
'Phường 14',
|
|
||||||
'Phường 15',
|
|
||||||
'Phường 17',
|
|
||||||
'Phường 19',
|
|
||||||
'Phường 21',
|
|
||||||
'Phường 22',
|
|
||||||
'Phường 24',
|
|
||||||
'Phường 25',
|
|
||||||
'Phường 26',
|
|
||||||
'Phường 27',
|
|
||||||
'Phường 28',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận Phú Nhuận',
|
|
||||||
wards: [
|
|
||||||
'Phường 1',
|
|
||||||
'Phường 2',
|
|
||||||
'Phường 3',
|
|
||||||
'Phường 4',
|
|
||||||
'Phường 5',
|
|
||||||
'Phường 7',
|
|
||||||
'Phường 8',
|
|
||||||
'Phường 9',
|
|
||||||
'Phường 10',
|
|
||||||
'Phường 11',
|
|
||||||
'Phường 13',
|
|
||||||
'Phường 15',
|
|
||||||
'Phường 17',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận Tân Bình',
|
|
||||||
wards: [
|
|
||||||
'Phường 1',
|
|
||||||
'Phường 2',
|
|
||||||
'Phường 3',
|
|
||||||
'Phường 4',
|
|
||||||
'Phường 5',
|
|
||||||
'Phường 6',
|
|
||||||
'Phường 7',
|
|
||||||
'Phường 8',
|
|
||||||
'Phường 9',
|
|
||||||
'Phường 10',
|
|
||||||
'Phường 11',
|
|
||||||
'Phường 12',
|
|
||||||
'Phường 13',
|
|
||||||
'Phường 14',
|
|
||||||
'Phường 15',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Quận Gò Vấp',
|
|
||||||
wards: [
|
|
||||||
'Phường 1',
|
|
||||||
'Phường 3',
|
|
||||||
'Phường 4',
|
|
||||||
'Phường 5',
|
|
||||||
'Phường 6',
|
|
||||||
'Phường 7',
|
|
||||||
'Phường 8',
|
|
||||||
'Phường 9',
|
|
||||||
'Phường 10',
|
|
||||||
'Phường 11',
|
|
||||||
'Phường 12',
|
|
||||||
'Phường 13',
|
|
||||||
'Phường 14',
|
|
||||||
'Phường 15',
|
|
||||||
'Phường 16',
|
|
||||||
'Phường 17',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const HANOI_DISTRICTS = [
|
|
||||||
{
|
|
||||||
district: 'Hoàn Kiếm',
|
|
||||||
wards: [
|
|
||||||
'Chương Dương',
|
|
||||||
'Cửa Đông',
|
|
||||||
'Cửa Nam',
|
|
||||||
'Đồng Xuân',
|
|
||||||
'Hàng Bạc',
|
|
||||||
'Hàng Bài',
|
|
||||||
'Hàng Bồ',
|
|
||||||
'Hàng Bông',
|
|
||||||
'Hàng Buồm',
|
|
||||||
'Hàng Đào',
|
|
||||||
'Hàng Gai',
|
|
||||||
'Hàng Mã',
|
|
||||||
'Hàng Trống',
|
|
||||||
'Lý Thái Tổ',
|
|
||||||
'Phan Chu Trinh',
|
|
||||||
'Phúc Tân',
|
|
||||||
'Tràng Tiền',
|
|
||||||
'Trần Hưng Đạo',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Ba Đình',
|
|
||||||
wards: [
|
|
||||||
'Cống Vị',
|
|
||||||
'Điện Biên',
|
|
||||||
'Đội Cấn',
|
|
||||||
'Giảng Võ',
|
|
||||||
'Kim Mã',
|
|
||||||
'Liễu Giai',
|
|
||||||
'Ngọc Hà',
|
|
||||||
'Ngọc Khánh',
|
|
||||||
'Nguyễn Trung Trực',
|
|
||||||
'Phúc Xá',
|
|
||||||
'Quán Thánh',
|
|
||||||
'Thành Công',
|
|
||||||
'Trúc Bạch',
|
|
||||||
'Vĩnh Phúc',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Đống Đa',
|
|
||||||
wards: [
|
|
||||||
'Cát Linh',
|
|
||||||
'Hàng Bột',
|
|
||||||
'Khâm Thiên',
|
|
||||||
'Khương Thượng',
|
|
||||||
'Kim Liên',
|
|
||||||
'Láng Hạ',
|
|
||||||
'Láng Thượng',
|
|
||||||
'Nam Đồng',
|
|
||||||
'Ngã Tư Sở',
|
|
||||||
'Ô Chợ Dừa',
|
|
||||||
'Phương Liên',
|
|
||||||
'Phương Mai',
|
|
||||||
'Quang Trung',
|
|
||||||
'Quốc Tử Giám',
|
|
||||||
'Thổ Quan',
|
|
||||||
'Trung Liệt',
|
|
||||||
'Trung Phụng',
|
|
||||||
'Trung Tự',
|
|
||||||
'Văn Chương',
|
|
||||||
'Văn Miếu',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Hai Bà Trưng',
|
|
||||||
wards: [
|
|
||||||
'Bách Khoa',
|
|
||||||
'Bạch Đằng',
|
|
||||||
'Bạch Mai',
|
|
||||||
'Bùi Thị Xuân',
|
|
||||||
'Cầu Dền',
|
|
||||||
'Đồng Mác',
|
|
||||||
'Đồng Nhân',
|
|
||||||
'Đồng Tâm',
|
|
||||||
'Lê Đại Hành',
|
|
||||||
'Minh Khai',
|
|
||||||
'Ngô Thì Nhậm',
|
|
||||||
'Nguyễn Du',
|
|
||||||
'Phạm Đình Hổ',
|
|
||||||
'Phố Huế',
|
|
||||||
'Quỳnh Lôi',
|
|
||||||
'Quỳnh Mai',
|
|
||||||
'Thanh Lương',
|
|
||||||
'Thanh Nhàn',
|
|
||||||
'Trương Định',
|
|
||||||
'Vĩnh Tuy',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Cầu Giấy',
|
|
||||||
wards: [
|
|
||||||
'Dịch Vọng',
|
|
||||||
'Dịch Vọng Hậu',
|
|
||||||
'Mai Dịch',
|
|
||||||
'Nghĩa Đô',
|
|
||||||
'Nghĩa Tân',
|
|
||||||
'Quan Hoa',
|
|
||||||
'Trung Hòa',
|
|
||||||
'Yên Hòa',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Tây Hồ',
|
|
||||||
wards: [
|
|
||||||
'Bưởi',
|
|
||||||
'Nhật Tân',
|
|
||||||
'Phú Thượng',
|
|
||||||
'Quảng An',
|
|
||||||
'Thụy Khuê',
|
|
||||||
'Tứ Liên',
|
|
||||||
'Xuân La',
|
|
||||||
'Yên Phụ',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
district: 'Nam Từ Liêm',
|
|
||||||
wards: [
|
|
||||||
'Cầu Diễn',
|
|
||||||
'Đại Mỗ',
|
|
||||||
'Mễ Trì',
|
|
||||||
'Mỹ Đình 1',
|
|
||||||
'Mỹ Đình 2',
|
|
||||||
'Phú Đô',
|
|
||||||
'Phương Canh',
|
|
||||||
'Tây Mỗ',
|
|
||||||
'Trung Văn',
|
|
||||||
'Xuân Phương',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Subscription Plans
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const PLANS = [
|
|
||||||
{
|
|
||||||
tier: PlanTier.FREE,
|
|
||||||
name: 'Miễn phí',
|
|
||||||
priceMonthlyVND: BigInt(0),
|
|
||||||
priceYearlyVND: BigInt(0),
|
|
||||||
maxListings: 3,
|
|
||||||
maxSavedSearches: 5,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 5,
|
|
||||||
analytics: false,
|
|
||||||
prioritySupport: false,
|
|
||||||
aiValuation: false,
|
|
||||||
featuredListing: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: PlanTier.AGENT_PRO,
|
|
||||||
name: 'Agent Pro',
|
|
||||||
priceMonthlyVND: BigInt(499_000),
|
|
||||||
priceYearlyVND: BigInt(4_990_000),
|
|
||||||
maxListings: 50,
|
|
||||||
maxSavedSearches: 30,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 30,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: PlanTier.INVESTOR,
|
|
||||||
name: 'Investor',
|
|
||||||
priceMonthlyVND: BigInt(999_000),
|
|
||||||
priceYearlyVND: BigInt(9_990_000),
|
|
||||||
maxListings: 20,
|
|
||||||
maxSavedSearches: 100,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 15,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: false,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: PlanTier.ENTERPRISE,
|
|
||||||
name: 'Enterprise',
|
|
||||||
priceMonthlyVND: BigInt(4_990_000),
|
|
||||||
priceYearlyVND: BigInt(49_900_000),
|
|
||||||
maxListings: null,
|
|
||||||
maxSavedSearches: null,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 100,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
apiAccess: true,
|
|
||||||
whiteLabel: true,
|
|
||||||
dedicatedSupport: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Sample coordinates for HCM districts
|
// Sample coordinates for HCM districts
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const SAMPLE_LOCATIONS: Record<string, { lat: number; lng: number }> = {
|
const SAMPLE_LOCATIONS = CITY_COORDINATES['Hồ Chí Minh'];
|
||||||
'Quận 1': { lat: 10.7769, lng: 106.7009 },
|
|
||||||
'Quận 3': { lat: 10.7834, lng: 106.6867 },
|
|
||||||
'Quận 7': { lat: 10.734, lng: 106.7218 },
|
|
||||||
'Thủ Đức': { lat: 10.8544, lng: 106.7536 },
|
|
||||||
'Quận Bình Thạnh': { lat: 10.8065, lng: 106.7098 },
|
|
||||||
'Quận Phú Nhuận': { lat: 10.7993, lng: 106.6815 },
|
|
||||||
'Quận Tân Bình': { lat: 10.8016, lng: 106.6525 },
|
|
||||||
'Quận Gò Vấp': { lat: 10.8384, lng: 106.6652 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Seed functions
|
// Seed functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
async function seedPlans() {
|
// seedPlans is imported from scripts/seed-plans.ts
|
||||||
console.log('Seeding subscription plans...');
|
|
||||||
for (const plan of PLANS) {
|
|
||||||
await prisma.plan.upsert({
|
|
||||||
where: { tier: plan.tier },
|
|
||||||
update: {
|
|
||||||
name: plan.name,
|
|
||||||
priceMonthlyVND: plan.priceMonthlyVND,
|
|
||||||
priceYearlyVND: plan.priceYearlyVND,
|
|
||||||
maxListings: plan.maxListings,
|
|
||||||
maxSavedSearches: plan.maxSavedSearches,
|
|
||||||
features: plan.features,
|
|
||||||
},
|
|
||||||
create: plan,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(` ✓ ${PLANS.length} plans seeded`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedUsers() {
|
async function seedUsers() {
|
||||||
console.log('Seeding sample users...');
|
console.log('Seeding sample users...');
|
||||||
@@ -717,49 +295,7 @@ async function seedProperties(users: Awaited<ReturnType<typeof seedUsers>>) {
|
|||||||
console.log(` ✓ ${sampleProperties.length} properties + listings seeded`);
|
console.log(` ✓ ${sampleProperties.length} properties + listings seeded`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedMarketIndex() {
|
// seedMarketIndex is now handled by importMarketData from scripts/import-market-data.ts
|
||||||
console.log('Seeding sample market index data...');
|
|
||||||
|
|
||||||
const districts = ['Quận 1', 'Quận 3', 'Quận 7', 'Thủ Đức', 'Quận Bình Thạnh'];
|
|
||||||
const periods = ['2025-Q4', '2026-Q1'];
|
|
||||||
|
|
||||||
for (const district of districts) {
|
|
||||||
for (const period of periods) {
|
|
||||||
for (const propertyType of [PropertyType.APARTMENT, PropertyType.TOWNHOUSE]) {
|
|
||||||
const basePrice =
|
|
||||||
district === 'Quận 1' ? 120_000_000 : district === 'Quận 7' ? 65_000_000 : 55_000_000;
|
|
||||||
const randomFactor = 0.9 + Math.random() * 0.2;
|
|
||||||
|
|
||||||
await prisma.marketIndex.upsert({
|
|
||||||
where: {
|
|
||||||
district_city_propertyType_period: {
|
|
||||||
district,
|
|
||||||
city: 'Hồ Chí Minh',
|
|
||||||
propertyType,
|
|
||||||
period,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
district,
|
|
||||||
city: 'Hồ Chí Minh',
|
|
||||||
propertyType,
|
|
||||||
period,
|
|
||||||
medianPrice: BigInt(Math.round(basePrice * randomFactor * 80)),
|
|
||||||
avgPriceM2: basePrice * randomFactor,
|
|
||||||
totalListings: Math.floor(100 + Math.random() * 500),
|
|
||||||
daysOnMarket: 30 + Math.random() * 60,
|
|
||||||
inventoryLevel: Math.floor(50 + Math.random() * 200),
|
|
||||||
absorptionRate: 0.3 + Math.random() * 0.4,
|
|
||||||
yoyChange: -5 + Math.random() * 15,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(' ✓ Market index data seeded');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main seed
|
// Main seed
|
||||||
@@ -771,7 +307,7 @@ async function main() {
|
|||||||
await seedPlans();
|
await seedPlans();
|
||||||
const users = await seedUsers();
|
const users = await seedUsers();
|
||||||
await seedProperties(users);
|
await seedProperties(users);
|
||||||
await seedMarketIndex();
|
await importMarketData();
|
||||||
|
|
||||||
console.log('\n✅ Seed completed successfully!');
|
console.log('\n✅ Seed completed successfully!');
|
||||||
}
|
}
|
||||||
|
|||||||
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 };
|
||||||
323
scripts/seed-districts.ts
Normal file
323
scripts/seed-districts.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Seed Vietnam district/ward data for development.
|
||||||
|
*
|
||||||
|
* Creates sample properties across major cities (HCM, Hanoi, Da Nang)
|
||||||
|
* to ensure district coverage in the dev database.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-districts.ts
|
||||||
|
* Idempotent: safe to run multiple times.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, PropertyType, Direction } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// District & Ward data — canonical source
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface DistrictData {
|
||||||
|
district: string;
|
||||||
|
wards: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HCM_DISTRICTS: DistrictData[] = [
|
||||||
|
{
|
||||||
|
district: 'Quận 1',
|
||||||
|
wards: [
|
||||||
|
'Bến Nghé', 'Bến Thành', 'Cầu Kho', 'Cầu Ông Lãnh', 'Cô Giang',
|
||||||
|
'Đa Kao', 'Nguyễn Cư Trinh', 'Nguyễn Thái Bình', 'Phạm Ngũ Lão', 'Tân Định',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận 3',
|
||||||
|
wards: [
|
||||||
|
'Phường 1', 'Phường 2', 'Phường 3', 'Phường 4', 'Phường 5',
|
||||||
|
'Phường 9', 'Phường 10', 'Phường 11', 'Phường 12', 'Phường 13',
|
||||||
|
'Phường 14', 'Võ Thị Sáu',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận 7',
|
||||||
|
wards: [
|
||||||
|
'Bình Thuận', 'Phú Mỹ', 'Phú Thuận', 'Tân Hưng', 'Tân Kiểng',
|
||||||
|
'Tân Phong', 'Tân Phú', 'Tân Quy', 'Tân Thuận Đông', 'Tân Thuận Tây',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Thủ Đức',
|
||||||
|
wards: [
|
||||||
|
'An Khánh', 'An Lợi Đông', 'An Phú', 'Bình Chiểu', 'Bình Thọ',
|
||||||
|
'Bình Trưng Đông', 'Bình Trưng Tây', 'Cát Lái', 'Hiệp Bình Chánh',
|
||||||
|
'Hiệp Bình Phước', 'Linh Chiểu', 'Linh Đông', 'Linh Tây', 'Linh Trung',
|
||||||
|
'Linh Xuân', 'Long Bình', 'Long Phước', 'Long Thạnh Mỹ', 'Long Trường',
|
||||||
|
'Phú Hữu', 'Phước Bình', 'Phước Long A', 'Phước Long B', 'Tam Bình',
|
||||||
|
'Tam Phú', 'Tân Phú', 'Thạnh Mỹ Lợi', 'Thảo Điền', 'Thủ Thiêm',
|
||||||
|
'Trường Thạnh', 'Trường Thọ',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận Bình Thạnh',
|
||||||
|
wards: [
|
||||||
|
'Phường 1', 'Phường 2', 'Phường 3', 'Phường 5', 'Phường 6',
|
||||||
|
'Phường 7', 'Phường 11', 'Phường 12', 'Phường 13', 'Phường 14',
|
||||||
|
'Phường 15', 'Phường 17', 'Phường 19', 'Phường 21', 'Phường 22',
|
||||||
|
'Phường 24', 'Phường 25', 'Phường 26', 'Phường 27', 'Phường 28',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận Phú Nhuận',
|
||||||
|
wards: [
|
||||||
|
'Phường 1', 'Phường 2', 'Phường 3', 'Phường 4', 'Phường 5',
|
||||||
|
'Phường 7', 'Phường 8', 'Phường 9', 'Phường 10', 'Phường 11',
|
||||||
|
'Phường 13', 'Phường 15', 'Phường 17',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận Tân Bình',
|
||||||
|
wards: [
|
||||||
|
'Phường 1', 'Phường 2', 'Phường 3', 'Phường 4', 'Phường 5',
|
||||||
|
'Phường 6', 'Phường 7', 'Phường 8', 'Phường 9', 'Phường 10',
|
||||||
|
'Phường 11', 'Phường 12', 'Phường 13', 'Phường 14', 'Phường 15',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Quận Gò Vấp',
|
||||||
|
wards: [
|
||||||
|
'Phường 1', 'Phường 3', 'Phường 4', 'Phường 5', 'Phường 6',
|
||||||
|
'Phường 7', 'Phường 8', 'Phường 9', 'Phường 10', 'Phường 11',
|
||||||
|
'Phường 12', 'Phường 13', 'Phường 14', 'Phường 15', 'Phường 16',
|
||||||
|
'Phường 17',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HANOI_DISTRICTS: DistrictData[] = [
|
||||||
|
{
|
||||||
|
district: 'Hoàn Kiếm',
|
||||||
|
wards: [
|
||||||
|
'Chương Dương', 'Cửa Đông', 'Cửa Nam', 'Đồng Xuân', 'Hàng Bạc',
|
||||||
|
'Hàng Bài', 'Hàng Bồ', 'Hàng Bông', 'Hàng Buồm', 'Hàng Đào',
|
||||||
|
'Hàng Gai', 'Hàng Mã', 'Hàng Trống', 'Lý Thái Tổ', 'Phan Chu Trinh',
|
||||||
|
'Phúc Tân', 'Tràng Tiền', 'Trần Hưng Đạo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Ba Đình',
|
||||||
|
wards: [
|
||||||
|
'Cống Vị', 'Điện Biên', 'Đội Cấn', 'Giảng Võ', 'Kim Mã',
|
||||||
|
'Liễu Giai', 'Ngọc Hà', 'Ngọc Khánh', 'Nguyễn Trung Trực', 'Phúc Xá',
|
||||||
|
'Quán Thánh', 'Thành Công', 'Trúc Bạch', 'Vĩnh Phúc',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Đống Đa',
|
||||||
|
wards: [
|
||||||
|
'Cát Linh', 'Hàng Bột', 'Khâm Thiên', 'Khương Thượng', 'Kim Liên',
|
||||||
|
'Láng Hạ', 'Láng Thượng', 'Nam Đồng', 'Ngã Tư Sở', 'Ô Chợ Dừa',
|
||||||
|
'Phương Liên', 'Phương Mai', 'Quang Trung', 'Quốc Tử Giám', 'Thổ Quan',
|
||||||
|
'Trung Liệt', 'Trung Phụng', 'Trung Tự', 'Văn Chương', 'Văn Miếu',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Hai Bà Trưng',
|
||||||
|
wards: [
|
||||||
|
'Bách Khoa', 'Bạch Đằng', 'Bạch Mai', 'Bùi Thị Xuân', 'Cầu Dền',
|
||||||
|
'Đồng Mác', 'Đồng Nhân', 'Đồng Tâm', 'Lê Đại Hành', 'Minh Khai',
|
||||||
|
'Ngô Thì Nhậm', 'Nguyễn Du', 'Phạm Đình Hổ', 'Phố Huế', 'Quỳnh Lôi',
|
||||||
|
'Quỳnh Mai', 'Thanh Lương', 'Thanh Nhàn', 'Trương Định', 'Vĩnh Tuy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Cầu Giấy',
|
||||||
|
wards: [
|
||||||
|
'Dịch Vọng', 'Dịch Vọng Hậu', 'Mai Dịch', 'Nghĩa Đô', 'Nghĩa Tân',
|
||||||
|
'Quan Hoa', 'Trung Hòa', 'Yên Hòa',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Tây Hồ',
|
||||||
|
wards: [
|
||||||
|
'Bưởi', 'Nhật Tân', 'Phú Thượng', 'Quảng An', 'Thụy Khuê',
|
||||||
|
'Tứ Liên', 'Xuân La', 'Yên Phụ',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Nam Từ Liêm',
|
||||||
|
wards: [
|
||||||
|
'Cầu Diễn', 'Đại Mỗ', 'Mễ Trì', 'Mỹ Đình 1', 'Mỹ Đình 2',
|
||||||
|
'Phú Đô', 'Phương Canh', 'Tây Mỗ', 'Trung Văn', 'Xuân Phương',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DANANG_DISTRICTS: DistrictData[] = [
|
||||||
|
{
|
||||||
|
district: 'Hải Châu',
|
||||||
|
wards: [
|
||||||
|
'Hải Châu 1', 'Hải Châu 2', 'Thạch Thang', 'Thanh Bình', 'Thuận Phước',
|
||||||
|
'Hòa Thuận Tây', 'Hòa Thuận Đông', 'Nam Dương', 'Phước Ninh',
|
||||||
|
'Bình Hiên', 'Bình Thuận', 'Hòa Cường Bắc', 'Hòa Cường Nam',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Thanh Khê',
|
||||||
|
wards: [
|
||||||
|
'Tam Thuận', 'Thanh Khê Đông', 'Thanh Khê Tây', 'Xuân Hà', 'Tân Chính',
|
||||||
|
'Chính Gián', 'Vĩnh Trung', 'Thạc Gián', 'An Khê', 'Hòa Khê',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Sơn Trà',
|
||||||
|
wards: [
|
||||||
|
'An Hải Bắc', 'An Hải Đông', 'An Hải Tây', 'Mân Thái', 'Nại Hiên Đông',
|
||||||
|
'Phước Mỹ', 'Thọ Quang',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Ngũ Hành Sơn',
|
||||||
|
wards: [
|
||||||
|
'Hòa Hải', 'Hòa Quý', 'Khuê Mỹ', 'Mỹ An',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Liên Chiểu',
|
||||||
|
wards: [
|
||||||
|
'Hòa Hiệp Bắc', 'Hòa Hiệp Nam', 'Hòa Khánh Bắc', 'Hòa Khánh Nam',
|
||||||
|
'Hòa Minh',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
district: 'Cẩm Lệ',
|
||||||
|
wards: [
|
||||||
|
'Hòa An', 'Hòa Phát', 'Hòa Thọ Đông', 'Hòa Thọ Tây', 'Hòa Xuân',
|
||||||
|
'Khuê Trung',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Coordinates for property generation
|
||||||
|
export const CITY_COORDINATES: Record<string, Record<string, { lat: number; lng: number }>> = {
|
||||||
|
'Hồ Chí Minh': {
|
||||||
|
'Quận 1': { lat: 10.7769, lng: 106.7009 },
|
||||||
|
'Quận 3': { lat: 10.7834, lng: 106.6867 },
|
||||||
|
'Quận 7': { lat: 10.734, lng: 106.7218 },
|
||||||
|
'Thủ Đức': { lat: 10.8544, lng: 106.7536 },
|
||||||
|
'Quận Bình Thạnh': { lat: 10.8065, lng: 106.7098 },
|
||||||
|
'Quận Phú Nhuận': { lat: 10.7993, lng: 106.6815 },
|
||||||
|
'Quận Tân Bình': { lat: 10.8016, lng: 106.6525 },
|
||||||
|
'Quận Gò Vấp': { lat: 10.8384, lng: 106.6652 },
|
||||||
|
},
|
||||||
|
'Hà Nội': {
|
||||||
|
'Hoàn Kiếm': { lat: 21.0285, lng: 105.8542 },
|
||||||
|
'Ba Đình': { lat: 21.0355, lng: 105.8193 },
|
||||||
|
'Đống Đa': { lat: 21.0155, lng: 105.8282 },
|
||||||
|
'Hai Bà Trưng': { lat: 21.0064, lng: 105.8594 },
|
||||||
|
'Cầu Giấy': { lat: 21.0313, lng: 105.7977 },
|
||||||
|
'Tây Hồ': { lat: 21.0645, lng: 105.8237 },
|
||||||
|
'Nam Từ Liêm': { lat: 21.0175, lng: 105.7588 },
|
||||||
|
},
|
||||||
|
'Đà Nẵng': {
|
||||||
|
'Hải Châu': { lat: 16.0544, lng: 108.2022 },
|
||||||
|
'Thanh Khê': { lat: 16.0674, lng: 108.1811 },
|
||||||
|
'Sơn Trà': { lat: 16.0894, lng: 108.2331 },
|
||||||
|
'Ngũ Hành Sơn': { lat: 16.0211, lng: 108.2462 },
|
||||||
|
'Liên Chiểu': { lat: 16.0777, lng: 108.1478 },
|
||||||
|
'Cẩm Lệ': { lat: 16.0186, lng: 108.2012 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAllDistricts(): { city: string; districts: DistrictData[] }[] {
|
||||||
|
return [
|
||||||
|
{ city: 'Hồ Chí Minh', districts: HCM_DISTRICTS },
|
||||||
|
{ city: 'Hà Nội', districts: HANOI_DISTRICTS },
|
||||||
|
{ city: 'Đà Nẵng', districts: DANANG_DISTRICTS },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Seed: create sample properties across all districts
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const PROPERTY_TEMPLATES = [
|
||||||
|
{ type: PropertyType.APARTMENT, titleFn: (d: string) => `Căn hộ cao cấp ${d}`, area: 75, beds: 2, baths: 2, dir: Direction.SOUTHEAST },
|
||||||
|
{ type: PropertyType.TOWNHOUSE, titleFn: (d: string) => `Nhà phố ${d} 1 trệt 2 lầu`, area: 120, beds: 3, baths: 3, dir: Direction.SOUTH },
|
||||||
|
{ type: PropertyType.LAND, titleFn: (d: string) => `Đất nền thổ cư ${d}`, area: 100, beds: null, baths: null, dir: Direction.EAST },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedDistrictProperties() {
|
||||||
|
console.log('Seeding district properties across HCM, Hanoi, Da Nang...\n');
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const { city, districts } of getAllDistricts()) {
|
||||||
|
console.log(` ${city}:`);
|
||||||
|
const coords = CITY_COORDINATES[city] ?? {};
|
||||||
|
|
||||||
|
for (const { district, wards } of districts) {
|
||||||
|
const ward = wards[0];
|
||||||
|
const template = PROPERTY_TEMPLATES[created % PROPERTY_TEMPLATES.length];
|
||||||
|
const loc = coords[district] ?? { lat: 10.0, lng: 106.0 };
|
||||||
|
// Small random offset so each property has unique coords
|
||||||
|
const jitterLat = (Math.random() - 0.5) * 0.005;
|
||||||
|
const jitterLng = (Math.random() - 0.5) * 0.005;
|
||||||
|
|
||||||
|
const propId = `dist-prop-${city.substring(0, 3).toLowerCase()}-${district.substring(0, 10).toLowerCase().replace(/\s/g, '-')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO "Property" (
|
||||||
|
"id", "propertyType", "title", "description", "address",
|
||||||
|
"ward", "district", "city", "location",
|
||||||
|
"areaM2", "bedrooms", "bathrooms", "direction",
|
||||||
|
"legalStatus", "createdAt", "updatedAt"
|
||||||
|
) VALUES (
|
||||||
|
${propId}, ${template.type}::"PropertyType",
|
||||||
|
${template.titleFn(district)},
|
||||||
|
${'Bất động sản mẫu dùng cho phát triển. ' + district + ', ' + city + '.'},
|
||||||
|
${'Số 1 Đường chính'},
|
||||||
|
${ward}, ${district}, ${city},
|
||||||
|
ST_SetSRID(ST_MakePoint(${loc.lng + jitterLng}, ${loc.lat + jitterLat}), 4326),
|
||||||
|
${template.area}, ${template.beds}, ${template.baths},
|
||||||
|
${template.dir}::"Direction",
|
||||||
|
${'Sổ hồng'}, NOW(), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT ("id") DO NOTHING
|
||||||
|
`;
|
||||||
|
created++;
|
||||||
|
} catch {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cityDistricts = districts.length;
|
||||||
|
console.log(` ${cityDistricts} districts processed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Total: ${created} properties created, ${skipped} skipped (already exist)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Seed Districts — Vietnam Real Estate Dev Data ===\n');
|
||||||
|
|
||||||
|
// Log summary
|
||||||
|
for (const { city, districts } of getAllDistricts()) {
|
||||||
|
const totalWards = districts.reduce((sum, d) => sum + d.wards.length, 0);
|
||||||
|
console.log(` ${city}: ${districts.length} districts, ${totalWards} wards`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
await seedDistrictProperties();
|
||||||
|
console.log('\nDone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run standalone or import as module
|
||||||
|
if (require.main === module) {
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed districts failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
}
|
||||||
129
scripts/seed-plans.ts
Normal file
129
scripts/seed-plans.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Seed subscription plans (FREE, AGENT_PRO, INVESTOR, ENTERPRISE).
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-plans.ts
|
||||||
|
* Idempotent: uses upsert on PlanTier unique constraint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, PlanTier } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const PLANS = [
|
||||||
|
{
|
||||||
|
tier: PlanTier.FREE,
|
||||||
|
name: 'Miễn phí',
|
||||||
|
priceMonthlyVND: BigInt(0),
|
||||||
|
priceYearlyVND: BigInt(0),
|
||||||
|
maxListings: 3,
|
||||||
|
maxSavedSearches: 5,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 5,
|
||||||
|
analytics: false,
|
||||||
|
prioritySupport: false,
|
||||||
|
aiValuation: false,
|
||||||
|
featuredListing: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: PlanTier.AGENT_PRO,
|
||||||
|
name: 'Agent Pro',
|
||||||
|
priceMonthlyVND: BigInt(499_000),
|
||||||
|
priceYearlyVND: BigInt(4_990_000),
|
||||||
|
maxListings: 50,
|
||||||
|
maxSavedSearches: 30,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 30,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: PlanTier.INVESTOR,
|
||||||
|
name: 'Investor',
|
||||||
|
priceMonthlyVND: BigInt(999_000),
|
||||||
|
priceYearlyVND: BigInt(9_990_000),
|
||||||
|
maxListings: 20,
|
||||||
|
maxSavedSearches: 100,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 15,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: false,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: PlanTier.ENTERPRISE,
|
||||||
|
name: 'Enterprise',
|
||||||
|
priceMonthlyVND: BigInt(4_990_000),
|
||||||
|
priceYearlyVND: BigInt(49_900_000),
|
||||||
|
maxListings: null,
|
||||||
|
maxSavedSearches: null,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 100,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
apiAccess: true,
|
||||||
|
whiteLabel: true,
|
||||||
|
dedicatedSupport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedPlans() {
|
||||||
|
console.log('Seeding subscription plans...\n');
|
||||||
|
|
||||||
|
for (const plan of PLANS) {
|
||||||
|
const result = await prisma.plan.upsert({
|
||||||
|
where: { tier: plan.tier },
|
||||||
|
update: {
|
||||||
|
name: plan.name,
|
||||||
|
priceMonthlyVND: plan.priceMonthlyVND,
|
||||||
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
|
maxListings: plan.maxListings,
|
||||||
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
features: plan.features,
|
||||||
|
},
|
||||||
|
create: plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthly = Number(plan.priceMonthlyVND).toLocaleString('vi-VN');
|
||||||
|
console.log(` ${plan.tier.padEnd(12)} ${plan.name.padEnd(14)} ${monthly} VND/tháng`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${PLANS.length} plans seeded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
seedPlans()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed plans failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
export { seedPlans };
|
||||||
Reference in New Issue
Block a user