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:
Ho Ngoc Hai
2026-04-16 05:08:05 +07:00
parent 93a390efb9
commit 8da488711b
27 changed files with 1715 additions and 162 deletions

View File

@@ -16,6 +16,14 @@ export interface ValuationRequest {
hasLegalPaper?: boolean;
latitude?: number;
longitude?: number;
/** Optional project ID for project-based valuation */
projectId?: string;
/** Image file for visual analysis */
imageUrl?: string;
/** Description text for AI context */
description?: string;
/** Request deep analysis (confidence explanation, more drivers) */
deepAnalysis?: boolean;
}
export interface ValuationComparable {
@@ -27,6 +35,11 @@ export interface ValuationComparable {
areaM2: number;
pricePerM2: number;
similarity: number;
propertyType?: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
yearBuilt?: number;
latitude?: number;
longitude?: number;
}
@@ -35,6 +48,37 @@ export interface PriceDriver {
feature: string;
impact: number;
direction: 'positive' | 'negative';
/** Human-readable explanation of this driver's impact */
explanation?: string;
}
export interface MarketContext {
avgPricePerM2: number;
medianPrice: number;
priceGrowthYoY: number;
demandIndex: number;
supplyCount: number;
avgDaysOnMarket: number;
district: string;
city: string;
period: string;
}
export interface ValuationHistoryPoint {
date: string;
estimatedPriceVND: number;
confidence: number;
}
export interface ConfidenceExplanation {
level: 'high' | 'medium' | 'low';
score: number;
factors: Array<{
factor: string;
contribution: 'positive' | 'negative';
detail: string;
}>;
summary: string;
}
export interface ValuationResult {
@@ -48,6 +92,10 @@ export interface ValuationResult {
priceDrivers: PriceDriver[];
modelVersion: string;
createdAt: string;
/** Enhanced fields from deep analysis */
confidenceExplanation?: ConfidenceExplanation;
marketContext?: MarketContext;
valuationHistory?: ValuationHistoryPoint[];
}
export interface ValuationHistoryItem {
@@ -68,27 +116,105 @@ export interface ValuationHistoryResponse {
limit: number;
}
export interface BatchValuationRequest {
properties: ValuationRequest[];
}
export interface BatchValuationResponse {
results: ValuationResult[];
totalProcessed: number;
errors: Array<{ index: number; message: string }>;
}
export interface ValuationCompareRequest {
propertyIds: string[];
}
export interface ValuationCompareResponse {
properties: Array<{
id: string;
valuation: ValuationResult;
property: {
title: string;
district: string;
city: string;
area: number;
propertyType: string;
};
}>;
}
export interface ProjectSuggestion {
id: string;
name: string;
district: string;
city: string;
type: string;
}
// ─── API ────────────────────────────────────────────────
export const valuationApi = {
/** Request AVM estimate via GET /analytics/valuation */
/** Request AVM estimate via POST /analytics/valuation */
predict: (data: ValuationRequest) => {
const params = new URLSearchParams();
if (data.latitude) params.set('latitude', String(data.latitude));
if (data.longitude) params.set('longitude', String(data.longitude));
if (data.area) params.set('areaM2', String(data.area));
if (data.propertyType) params.set('propertyType', data.propertyType);
const qs = params.toString();
return apiClient.get<ValuationResult>(`/analytics/valuation${qs ? `?${qs}` : ''}`);
// Build request body with all fields
const body: Record<string, unknown> = {
propertyType: data.propertyType,
areaM2: data.area,
district: data.district,
city: data.city,
};
if (data.bedrooms != null) body['bedrooms'] = data.bedrooms;
if (data.bathrooms != null) body['bathrooms'] = data.bathrooms;
if (data.floors != null) body['floors'] = data.floors;
if (data.frontage != null) body['frontage'] = data.frontage;
if (data.roadWidth != null) body['roadWidth'] = data.roadWidth;
if (data.yearBuilt != null) body['yearBuilt'] = data.yearBuilt;
if (data.hasLegalPaper != null) body['hasLegalPaper'] = data.hasLegalPaper;
if (data.latitude) body['latitude'] = data.latitude;
if (data.longitude) body['longitude'] = data.longitude;
if (data.projectId) body['projectId'] = data.projectId;
if (data.imageUrl) body['imageUrl'] = data.imageUrl;
if (data.description) body['description'] = data.description;
if (data.deepAnalysis) body['deepAnalysis'] = data.deepAnalysis;
return apiClient.post<ValuationResult>('/analytics/valuation', body);
},
/** History is not available server-side — return empty result */
getHistory: (_page = 1, _limit = 10): Promise<ValuationHistoryResponse> =>
Promise.resolve({ data: [], total: 0, page: _page, limit: _limit }),
/** Batch valuation: POST /analytics/valuation/batch (max 50) */
batchPredict: (data: BatchValuationRequest) =>
apiClient.post<BatchValuationResponse>('/analytics/valuation/batch', data),
/** Get valuation history for a property: GET /analytics/valuation/history/:propertyId */
getPropertyHistory: (propertyId: string) =>
apiClient.get<{ data: ValuationHistoryPoint[] }>(
`/analytics/valuation/history/${propertyId}`,
),
/** Compare valuations: POST /analytics/valuation/compare */
compare: (data: ValuationCompareRequest) =>
apiClient.post<ValuationCompareResponse>('/analytics/valuation/compare', data),
/** User valuation history (paginated) */
getHistory: (page = 1, limit = 10) =>
apiClient.get<ValuationHistoryResponse>(
`/analytics/valuation/user-history?page=${page}&limit=${limit}`,
),
/** Get single valuation by ID */
getById: (id: string) =>
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${id}`),
apiClient.get<ValuationResult>(`/analytics/valuation/${id}`),
/** Predict for existing listing */
predictForListing: (listingId: string) =>
apiClient.get<ValuationResult>(`/analytics/valuation?propertyId=${listingId}`),
apiClient.post<ValuationResult>('/analytics/valuation', {
propertyId: listingId,
}),
/** Search projects for autocomplete */
searchProjects: (query: string) =>
apiClient.get<{ data: ProjectSuggestion[] }>(
`/projects/search?q=${encodeURIComponent(query)}&limit=10`,
),
};