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 { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
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 { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.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 { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.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 { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ const QueryHandlers = [
|
|||||||
BatchValuationHandler,
|
BatchValuationHandler,
|
||||||
ValuationHistoryHandler,
|
ValuationHistoryHandler,
|
||||||
ValuationComparisonHandler,
|
ValuationComparisonHandler,
|
||||||
|
GetNeighborhoodScoreHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventHandlers = [
|
const EventHandlers = [
|
||||||
@@ -59,6 +63,9 @@ const EventHandlers = [
|
|||||||
PrismaAVMService,
|
PrismaAVMService,
|
||||||
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
{ provide: AVM_SERVICE, useClass: HttpAVMService },
|
||||||
|
|
||||||
|
// Neighborhood scoring
|
||||||
|
{ provide: NEIGHBORHOOD_SCORE_SERVICE, useClass: NeighborhoodScoreServiceImpl },
|
||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
MarketIndexCronService,
|
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 { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||||
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
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 { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||||
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||||
@@ -158,4 +160,17 @@ export class AnalyticsController {
|
|||||||
new ValuationComparisonQuery(dto.propertyIds),
|
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