Add batch valuation (POST /analytics/valuation/batch, max 50 properties), valuation comparison (POST /analytics/valuation/compare, 2-5 properties), and history endpoint (GET /analytics/valuation/history/:propertyId) with confidence explanation helper. Frontend: enhanced valuation form with project autocomplete and deep analysis toggle, results with confidence badges and price range visualization, comparables table, history chart, market context card, and PDF export. Co-Authored-By: Paperclip <noreply@paperclip.ing>
152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { type PrismaService, type 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 { type 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<ValuationResult> {
|
|
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<Comparable[]> {
|
|
return this.fallback.getComparables(propertyId, radiusMeters);
|
|
}
|
|
|
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
|
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<ValuationResult> {
|
|
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',
|
|
};
|
|
}
|
|
}
|