Some checks failed
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 13s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 57s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Security Scanning / Trivy Scan — Web Image (push) Failing after 35s
- price-area-chart + sparkline: replace non-existent `var(--color-signal-up)` with proper `hsl(var(--signal-up))` (and same for -down + border + muted-foreground). The previous tokens resolved to undefined, leaving the chart line + sparkline invisible against the dark background. - public/page: switch `currentPeriod()` from monthly (YYYY-MM) to quarterly (YYYY-Qn) to match the MarketIndex aggregation period — heatmap and district stats now find rows. - import-market-data: add `2026-Q2` to seeded periods so the current quarter has data on a freshly seeded dev DB. - new scripts/seed-bulk-listings-per-district.ts: top up the dev DB with 12 synthetic listings per district per 7-day window so the movers query (which requires >= 10 listings/district/window) has signal to compute against. - update price-area-chart.spec to match new color tokens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
7.0 KiB
TypeScript
288 lines
7.0 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 { 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<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', '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 };
|