Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m31s
Deploy / Build API Image (push) Failing after 25s
E2E Tests / Playwright E2E (push) Failing after 23s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Deploy / Build Web Image (push) Failing after 17s
Deploy / Build AI Services Image (push) Failing after 13s
Security Scanning / Trivy Scan — Web Image (push) Failing after 58s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 51s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m55s
Security Scanning / Trivy Filesystem Scan (push) Failing after 45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 3s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Closes the last gap from the tec-2725 branch: the valuation form's v2
extended-features section and POST endpoint can now submit real
predictions through to the Python ensemble model.
Backend
- New DTO apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts
with all v1 fields + 8 v2 fields (useV2 toggle, distanceToHospital/Park/
Mall in km, floodZoneRisk enum NONE|LOW|MEDIUM|HIGH, hasElevator/
Parking/Pool booleans).
- New CQRS handler apps/api/src/modules/analytics/application/queries/
predict-valuation/ that routes to AVM_SERVICE.estimateValue() with the
full request body.
- Extend AVMParams (domain) with the same v2 fields + inline v1 fields
(district, city, bedrooms, bathrooms, floors, frontage, roadWidth,
hasLegalPaper, projectId, imageUrl, description, deepAnalysis).
- HttpAVMService.estimateViaAi now branches on `useV2`: v2 calls the new
aiClient.predictV2() → POST /avm/v2/predict on the Python service,
mapping floodZoneRisk enum → 0..1 float and computing
building_age_years from yearBuilt. v1 path gets all the inline
descriptors wired through so non-propertyId calls no longer lose
context.
- AiServiceClient gets AiPredictV2Request / AiPredictV2Response types
mirroring libs/ai-services/app/models/avm_v2.py::AVMv2PredictRequest
(which already accepts all 7 numeric/boolean v2 fields — no Python
change needed).
- Register PredictValuationHandler in AnalyticsModule.
- New route POST /analytics/valuation on AnalyticsController:
JwtAuthGuard + QuotaGuard + EndpointRateLimitGuard (10/min),
@RequireQuota('analytics_queries'), full Swagger doc. Total endpoint
count 179 → 180.
Frontend
- Extend ValuationRequest with useV2, 3 distance-km fields,
floodZoneRisk, hasElevator/Parking/Pool + export FloodZoneRisk type
and FLOOD_RISK_OPTIONS.
- valuationApi.predict() body mapping now includes v2 fields and renames
'areaM2' → 'area' to match the backend DTO contract.
- valuationFormSchema gains matching optional Zod fields + exports
FLOOD_RISK_OPTIONS for the form.
- valuation-form.tsx gets:
* Image upload hardening: MIME+size validation (JPG/PNG ≤5MB) before
preview, role="progressbar" + aria-labels on the progress bar,
role="alert" + data-testid="image-upload-error" on errors. Matches
the upload-progress part of the task/tec-2725 commit 4ee0129 that
was previously parked as blocked.
* New Sparkles-branded "Mô hình v2 (Ensemble)" toggle alongside the
existing Bot-branded "Phân tích chuyên sâu" toggle.
* Collapsible "Đặc trưng mở rộng (AVM v2)" section with distance
inputs, flood-risk select, and three amenity checkboxes.
* handleFormSubmit passes all v2 fields through to onSubmit.
Python service unchanged — AVMv2PredictRequest already has every field
we send (distance_to_hospital_km, flood_zone_risk as float,
has_elevator/parking/pool, etc.).
Typecheck clean for the valuation surface. Pre-existing errors in
metadata.spec.ts and transfer-wizard-client.tsx are unrelated and left
for a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
248 lines
7.2 KiB
TypeScript
248 lines
7.2 KiB
TypeScript
import { apiClient } from './api-client';
|
|
|
|
// ─── Types ──────────────────────────────────────────────
|
|
|
|
export type FloodZoneRisk = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
|
|
|
|
export const FLOOD_RISK_OPTIONS: { value: FloodZoneRisk; label: string }[] = [
|
|
{ value: 'NONE', label: 'Không có' },
|
|
{ value: 'LOW', label: 'Thấp' },
|
|
{ value: 'MEDIUM', label: 'Trung bình' },
|
|
{ value: 'HIGH', label: 'Cao' },
|
|
];
|
|
|
|
export interface ValuationRequest {
|
|
propertyType: string;
|
|
area: number;
|
|
district: string;
|
|
city: string;
|
|
bedrooms?: number;
|
|
bathrooms?: number;
|
|
floors?: number;
|
|
frontage?: number;
|
|
roadWidth?: number;
|
|
yearBuilt?: number;
|
|
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;
|
|
/** Use AVM v2 ensemble model (extended features) */
|
|
useV2?: boolean;
|
|
distanceToHospitalKm?: number;
|
|
distanceToParkKm?: number;
|
|
distanceToMallKm?: number;
|
|
floodZoneRisk?: FloodZoneRisk;
|
|
hasElevator?: boolean;
|
|
hasParking?: boolean;
|
|
hasPool?: boolean;
|
|
}
|
|
|
|
export interface ValuationComparable {
|
|
id: string;
|
|
title: string;
|
|
address: string;
|
|
district: string;
|
|
priceVND: string;
|
|
areaM2: number;
|
|
pricePerM2: number;
|
|
similarity: number;
|
|
propertyType?: string;
|
|
bedrooms?: number;
|
|
bathrooms?: number;
|
|
floors?: number;
|
|
yearBuilt?: number;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
}
|
|
|
|
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 {
|
|
id: string;
|
|
estimatedPriceVND: number;
|
|
confidence: number;
|
|
pricePerM2: number;
|
|
priceRangeLow: number;
|
|
priceRangeHigh: number;
|
|
comparables: ValuationComparable[];
|
|
priceDrivers: PriceDriver[];
|
|
modelVersion: string;
|
|
createdAt: string;
|
|
/** Enhanced fields from deep analysis */
|
|
confidenceExplanation?: ConfidenceExplanation;
|
|
marketContext?: MarketContext;
|
|
valuationHistory?: ValuationHistoryPoint[];
|
|
}
|
|
|
|
export interface ValuationHistoryItem {
|
|
id: string;
|
|
propertyType: string;
|
|
district: string;
|
|
city: string;
|
|
area: number;
|
|
estimatedPriceVND: number;
|
|
confidence: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ValuationHistoryResponse {
|
|
data: ValuationHistoryItem[];
|
|
total: number;
|
|
page: number;
|
|
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 POST /analytics/valuation */
|
|
predict: (data: ValuationRequest) => {
|
|
const body: Record<string, unknown> = {
|
|
propertyType: data.propertyType,
|
|
area: 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;
|
|
|
|
// AVM v2 fields
|
|
if (data.useV2) body['useV2'] = data.useV2;
|
|
if (data.distanceToHospitalKm != null) body['distanceToHospitalKm'] = data.distanceToHospitalKm;
|
|
if (data.distanceToParkKm != null) body['distanceToParkKm'] = data.distanceToParkKm;
|
|
if (data.distanceToMallKm != null) body['distanceToMallKm'] = data.distanceToMallKm;
|
|
if (data.floodZoneRisk) body['floodZoneRisk'] = data.floodZoneRisk;
|
|
if (data.hasElevator != null) body['hasElevator'] = data.hasElevator;
|
|
if (data.hasParking != null) body['hasParking'] = data.hasParking;
|
|
if (data.hasPool != null) body['hasPool'] = data.hasPool;
|
|
|
|
return apiClient.post<ValuationResult>('/analytics/valuation', body);
|
|
},
|
|
|
|
/** 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/${id}`),
|
|
|
|
/** Predict for existing listing */
|
|
predictForListing: (listingId: string) =>
|
|
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`,
|
|
),
|
|
};
|