import { Inject, Injectable } from '@nestjs/common'; import { PrismaService, LoggerService } from '@modules/shared'; import { type IAVMService, type AVMParams, type ValuationResult, type Comparable, type BatchValuationItem, type BatchValuationResult, } from '../../domain/services/avm-service'; import { AI_SERVICE_CLIENT, type IAiServiceClient, type AiPredictRequest, } from './ai-service.client'; import { PrismaAVMService } from './prisma-avm.service'; /** Max concurrency for batch AI calls to avoid overloading the Python service. */ const BATCH_CONCURRENCY = 5; @Injectable() export class HttpAVMService implements IAVMService { constructor( @Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient, private readonly fallback: PrismaAVMService, private readonly prisma: PrismaService, private readonly logger: LoggerService, ) {} async estimateValue(params: AVMParams): Promise { try { return await this.estimateViaAi(params); } catch (err) { this.logger.warn( `AI AVM service unavailable, falling back to comparables-based estimation: ${(err as Error).message}`, 'HttpAVMService', ); return this.fallback.estimateValue(params); } } async getComparables(propertyId: string, radiusMeters: number): Promise { return this.fallback.getComparables(propertyId, radiusMeters); } async estimateBatch(items: BatchValuationItem[]): Promise { const results: BatchValuationResult[] = []; // Process in batches with limited concurrency for (let i = 0; i < items.length; i += BATCH_CONCURRENCY) { const chunk = items.slice(i, i + BATCH_CONCURRENCY); const chunkResults = await Promise.allSettled( chunk.map(async (item) => { const valuation = await this.estimateValue({ propertyId: item.propertyId }); return { propertyId: item.propertyId, valuation } as BatchValuationResult; }), ); for (let j = 0; j < chunkResults.length; j++) { const result = chunkResults[j]!; const item = chunk[j]!; if (result.status === 'fulfilled') { results.push(result.value); } else { this.logger.warn( `Batch valuation failed for property ${item.propertyId}: ${String(result.reason)}`, 'HttpAVMService', ); results.push({ propertyId: item.propertyId, valuation: null, error: result.reason instanceof Error ? result.reason.message : 'Lỗi định giá', }); } } } return results; } private async estimateViaAi(params: AVMParams): Promise { const propertyData = params.propertyId ? await this.getPropertyDetails(params.propertyId) : null; const request: AiPredictRequest = { area: params.areaM2 ?? propertyData?.areaM2 ?? 0, district: propertyData?.district ?? '', city: propertyData?.city ?? '', property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(), bedrooms: propertyData?.bedrooms ?? 0, bathrooms: propertyData?.bathrooms ?? 0, floors: propertyData?.floors ?? 0, frontage: 0, road_width: 0, year_built: params.yearBuilt ?? propertyData?.yearBuilt, has_legal_paper: propertyData?.hasLegalPaper ?? true, }; const aiResult = await this.aiClient.predict(request); // Also fetch comparables from the local PostGIS service for context let comparables: Comparable[] = []; try { if (params.propertyId) { comparables = await this.fallback.getComparables(params.propertyId, 2000); } } catch { // Comparables are supplementary — don't fail the valuation } return { estimatedPrice: Math.round(aiResult.estimated_price_vnd).toString(), confidence: aiResult.confidence, pricePerM2: Math.round(aiResult.price_per_m2), comparables, modelVersion: 'ai-service-v1.0', }; } private async getPropertyDetails(propertyId: string) { const row = await this.prisma.property.findUnique({ where: { id: propertyId }, select: { areaM2: true, district: true, city: true, propertyType: true, bedrooms: true, bathrooms: true, floors: true, yearBuilt: true, legalStatus: true, }, }); if (!row) return null; return { areaM2: row.areaM2, district: row.district, city: row.city, propertyType: row.propertyType, bedrooms: row.bedrooms ?? 0, bathrooms: row.bathrooms ?? 0, floors: row.floors ?? 0, yearBuilt: row.yearBuilt, hasLegalPaper: row.legalStatus === 'SO_DO' || row.legalStatus === 'SO_HONG', }; } }