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:
Ho Ngoc Hai
2026-04-16 05:21:28 +07:00
parent 5db3dfbda6
commit 30d3039b94
6 changed files with 214 additions and 0 deletions

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
export class GetNeighborhoodScoreQuery {
constructor(
public readonly district: string,
public readonly city: string,
) {}
}

View File

@@ -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>;
}

View File

@@ -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 0100 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 010: linear scale capped at MAX_COUNTS
const maxCount = MAX_COUNTS[category]!;
categoryScores[category] = Math.min(10, (count / maxCount) * 10);
}
// Weighted total score (0100)
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,
};
}
}

View File

@@ -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),
);
}
}