feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade
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>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Generates a human-readable Vietnamese explanation of the AVM confidence score.
|
||||
*
|
||||
* The explanation considers:
|
||||
* - Overall confidence level (high/medium/low)
|
||||
* - Number of comparable properties used
|
||||
* - General market data quality
|
||||
*/
|
||||
export function generateConfidenceExplanation(
|
||||
confidence: number,
|
||||
comparableCount: number,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Confidence level description
|
||||
if (confidence >= 0.8) {
|
||||
parts.push('Mức độ tin cậy cao');
|
||||
} else if (confidence >= 0.5) {
|
||||
parts.push('Mức độ tin cậy trung bình');
|
||||
} else if (confidence > 0) {
|
||||
parts.push('Mức độ tin cậy thấp');
|
||||
} else {
|
||||
return 'Không đủ dữ liệu để đưa ra ước tính đáng tin cậy. Kết quả chỉ mang tính tham khảo.';
|
||||
}
|
||||
|
||||
parts.push(`(${Math.round(confidence * 100)}%).`);
|
||||
|
||||
// Comparable properties context
|
||||
if (comparableCount >= 10) {
|
||||
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương trong khu vực, cung cấp cơ sở dữ liệu vững chắc.`);
|
||||
} else if (comparableCount >= 5) {
|
||||
parts.push(`Dựa trên ${comparableCount} bất động sản tương đương. Dữ liệu đủ để ước tính hợp lý.`);
|
||||
} else if (comparableCount >= 3) {
|
||||
parts.push(`Chỉ có ${comparableCount} bất động sản tương đương. Kết quả có thể dao động.`);
|
||||
} else {
|
||||
parts.push('Số lượng bất động sản tương đương hạn chế. Nên tham khảo thêm các nguồn khác.');
|
||||
}
|
||||
|
||||
// Additional guidance based on confidence
|
||||
if (confidence < 0.5) {
|
||||
parts.push('Khuyến nghị: Nên tham vấn chuyên gia định giá để có kết quả chính xác hơn.');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
IAVMService,
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
type ValuationResult,
|
||||
type Comparable,
|
||||
type BatchValuationItem,
|
||||
type BatchValuationResult,
|
||||
} from '../../domain/services/avm-service';
|
||||
import {
|
||||
AI_SERVICE_CLIENT,
|
||||
IAiServiceClient,
|
||||
type IAiServiceClient,
|
||||
type AiPredictRequest,
|
||||
} from './ai-service.client';
|
||||
import { PrismaAVMService } from './prisma-avm.service';
|
||||
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 {
|
||||
@@ -38,6 +43,41 @@ export class HttpAVMService implements IAVMService {
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PropertyType } from '@prisma/client';
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
IAVMService,
|
||||
type IAVMService,
|
||||
type AVMParams,
|
||||
type ValuationResult,
|
||||
type Comparable,
|
||||
type BatchValuationItem,
|
||||
type BatchValuationResult,
|
||||
} from '../../domain/services/avm-service';
|
||||
import {
|
||||
type RawComparable,
|
||||
@@ -68,6 +70,19 @@ export class PrismaAVMService implements IAVMService {
|
||||
return raws.map(toComparableDto);
|
||||
}
|
||||
|
||||
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
||||
return { propertyId: item.propertyId, valuation };
|
||||
} catch {
|
||||
return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveParams(params: AVMParams): Promise<{
|
||||
lat: number; lng: number; areaM2: number;
|
||||
propertyType: PropertyType | undefined;
|
||||
|
||||
Reference in New Issue
Block a user