feat(analytics): add NeighborhoodScoreService with POI-based scoring and API endpoint
- Create INeighborhoodScoreService interface and implementation - Score districts 0-100 across 6 categories: education, healthcare, transport, shopping, greenery, safety - Calculate scores from POI data with configurable weights and max counts - Add GetNeighborhoodScoreQuery handler with lazy calculation - Add GET /analytics/neighborhoods/:district/score endpoint - Wire service and handler into AnalyticsModule Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||
@@ -20,6 +21,8 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma-
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import { NEIGHBORHOOD_SCORE_SERVICE } from './domain/services/neighborhood-score.service';
|
||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||
|
||||
@@ -38,6 +41,7 @@ const QueryHandlers = [
|
||||
BatchValuationHandler,
|
||||
ValuationHistoryHandler,
|
||||
ValuationComparisonHandler,
|
||||
GetNeighborhoodScoreHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
@@ -59,6 +63,9 @@ const EventHandlers = [
|
||||
PrismaAVMService,
|
||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||
|
||||
// Neighborhood scoring
|
||||
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||
|
||||
// Cron
|
||||
MarketIndexCronService,
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
NEIGHBORHOOD_SCORE_SERVICE,
|
||||
type INeighborhoodScoreService,
|
||||
type NeighborhoodScoreResult,
|
||||
} from '../../../domain/services/neighborhood-score.service';
|
||||
import { GetNeighborhoodScoreQuery } from './get-neighborhood-score.query';
|
||||
|
||||
@QueryHandler(GetNeighborhoodScoreQuery)
|
||||
export class GetNeighborhoodScoreHandler implements IQueryHandler<GetNeighborhoodScoreQuery> {
|
||||
constructor(
|
||||
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
||||
private readonly scoreService: INeighborhoodScoreService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
||||
// Return cached score if available, otherwise calculate
|
||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||
if (existing) return existing;
|
||||
|
||||
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetNeighborhoodScoreQuery {
|
||||
constructor(
|
||||
public readonly district: string,
|
||||
public readonly city: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const NEIGHBORHOOD_SCORE_SERVICE = Symbol('NEIGHBORHOOD_SCORE_SERVICE');
|
||||
|
||||
export interface NeighborhoodScoreResult {
|
||||
district: string;
|
||||
city: string;
|
||||
educationScore: number;
|
||||
healthcareScore: number;
|
||||
transportScore: number;
|
||||
shoppingScore: number;
|
||||
greeneryScore: number;
|
||||
safetyScore: number;
|
||||
totalScore: number;
|
||||
poiCounts: Record<string, number>;
|
||||
calculatedAt: Date;
|
||||
}
|
||||
|
||||
export interface INeighborhoodScoreService {
|
||||
getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null>;
|
||||
calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult>;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
type INeighborhoodScoreService,
|
||||
type NeighborhoodScoreResult,
|
||||
} from '../../domain/services/neighborhood-score.service';
|
||||
|
||||
/**
|
||||
* Scoring weights for each POI category.
|
||||
* Sum = 100 (total score is 0–100 weighted average).
|
||||
*/
|
||||
const CATEGORY_WEIGHTS = {
|
||||
education: 20,
|
||||
healthcare: 20,
|
||||
transport: 20,
|
||||
shopping: 15,
|
||||
greenery: 15,
|
||||
safety: 10,
|
||||
};
|
||||
|
||||
/** POI types grouped by scoring category. */
|
||||
const CATEGORY_POI_TYPES: Record<string, string[]> = {
|
||||
education: ['SCHOOL', 'UNIVERSITY'],
|
||||
healthcare: ['HOSPITAL', 'CLINIC'],
|
||||
transport: ['METRO_STATION', 'BUS_STOP'],
|
||||
shopping: ['MALL', 'MARKET', 'SUPERMARKET'],
|
||||
greenery: ['PARK'],
|
||||
safety: ['POLICE_STATION', 'FIRE_STATION'],
|
||||
};
|
||||
|
||||
/** Max count per category that yields a 10/10 score. */
|
||||
const MAX_COUNTS: Record<string, number> = {
|
||||
education: 15,
|
||||
healthcare: 8,
|
||||
transport: 12,
|
||||
shopping: 10,
|
||||
greenery: 6,
|
||||
safety: 4,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NeighborhoodScoreServiceImpl implements INeighborhoodScoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async getScore(district: string, city: string): Promise<NeighborhoodScoreResult | null> {
|
||||
const existing = await this.prisma.neighborhoodScore.findUnique({
|
||||
where: { district_city: { district, city } },
|
||||
});
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
return {
|
||||
district: existing.district,
|
||||
city: existing.city,
|
||||
educationScore: existing.educationScore,
|
||||
healthcareScore: existing.healthcareScore,
|
||||
transportScore: existing.transportScore,
|
||||
shoppingScore: existing.shoppingScore,
|
||||
greeneryScore: existing.greeneryScore,
|
||||
safetyScore: existing.safetyScore,
|
||||
totalScore: existing.totalScore,
|
||||
poiCounts: existing.poiCounts as Record<string, number>,
|
||||
calculatedAt: existing.calculatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async calculateAndSave(district: string, city: string): Promise<NeighborhoodScoreResult> {
|
||||
// Count POIs per category for this district
|
||||
const poiCounts: Record<string, number> = {};
|
||||
const categoryScores: Record<string, number> = {};
|
||||
|
||||
for (const [category, poiTypes] of Object.entries(CATEGORY_POI_TYPES)) {
|
||||
const count = await this.prisma.pOI.count({
|
||||
where: {
|
||||
district,
|
||||
city,
|
||||
type: { in: poiTypes as any },
|
||||
},
|
||||
});
|
||||
|
||||
poiCounts[category] = count;
|
||||
// Score 0–10: linear scale capped at MAX_COUNTS
|
||||
const maxCount = MAX_COUNTS[category]!;
|
||||
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
|
||||
}
|
||||
|
||||
// Weighted total score (0–100)
|
||||
const totalScore = Object.entries(CATEGORY_WEIGHTS).reduce((sum, [cat, weight]) => {
|
||||
return sum + (categoryScores[cat]! * weight) / 10;
|
||||
}, 0);
|
||||
|
||||
const result = await this.prisma.neighborhoodScore.upsert({
|
||||
where: { district_city: { district, city } },
|
||||
create: {
|
||||
district,
|
||||
city,
|
||||
educationScore: categoryScores['education']!,
|
||||
healthcareScore: categoryScores['healthcare']!,
|
||||
transportScore: categoryScores['transport']!,
|
||||
shoppingScore: categoryScores['shopping']!,
|
||||
greeneryScore: categoryScores['greenery']!,
|
||||
safetyScore: categoryScores['safety']!,
|
||||
totalScore: Math.round(totalScore * 10) / 10,
|
||||
poiCounts,
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
educationScore: categoryScores['education']!,
|
||||
healthcareScore: categoryScores['healthcare']!,
|
||||
transportScore: categoryScores['transport']!,
|
||||
shoppingScore: categoryScores['shopping']!,
|
||||
greeneryScore: categoryScores['greenery']!,
|
||||
safetyScore: categoryScores['safety']!,
|
||||
totalScore: Math.round(totalScore * 10) / 10,
|
||||
poiCounts,
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Neighborhood score calculated: ${district}, ${city} → total=${result.totalScore}`,
|
||||
'NeighborhoodScoreService',
|
||||
);
|
||||
|
||||
return {
|
||||
district: result.district,
|
||||
city: result.city,
|
||||
educationScore: result.educationScore,
|
||||
healthcareScore: result.healthcareScore,
|
||||
transportScore: result.transportScore,
|
||||
shoppingScore: result.shoppingScore,
|
||||
greeneryScore: result.greeneryScore,
|
||||
safetyScore: result.safetyScore,
|
||||
totalScore: result.totalScore,
|
||||
poiCounts: result.poiCounts as Record<string, number>,
|
||||
calculatedAt: result.calculatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { type ValuationDto } from '../../application/queries/get-valuation/get-v
|
||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||
import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
@@ -158,4 +160,17 @@ export class AnalyticsController {
|
||||
new ValuationComparisonQuery(dto.propertyIds),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get neighborhood score for a district' })
|
||||
@ApiParam({ name: 'district', description: 'District name', example: 'Quận 1' })
|
||||
@ApiResponse({ status: 200, description: 'Neighborhood score retrieved' })
|
||||
@Get('neighborhoods/:district/score')
|
||||
async getNeighborhoodScore(
|
||||
@Param('district') district: string,
|
||||
@Query('city') city: string = 'Hồ Chí Minh',
|
||||
): Promise<NeighborhoodScoreResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetNeighborhoodScoreQuery(district, city),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user