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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user