feat(avm): end-to-end AVM v2 schema + POST /analytics/valuation endpoint
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>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 06:49:57 +07:00
parent 58b0e6ba12
commit 79e173938b
12 changed files with 736 additions and 19 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Bot, ImagePlus, Search, X } from 'lucide-react';
import { Bot, ChevronDown, ImagePlus, Search, Sparkles, X } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
@@ -21,6 +21,7 @@ import {
type ValuationFormData,
VALUATION_PROPERTY_TYPES,
CITIES,
FLOOD_RISK_OPTIONS,
} from '@/lib/validations/valuation';
import type { ValuationRequest } from '@/lib/valuation-api';
@@ -60,8 +61,13 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
// Image upload state
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// v2 section collapsible state
const [showV2Section, setShowV2Section] = useState(false);
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setProjectQuery(value);
@@ -85,17 +91,45 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
const handleImageChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setUploadError(null);
if (!file) return;
// Show local preview
// Validate MIME type and size before anything else
const ALLOWED = ['image/jpeg', 'image/png'];
if (!ALLOWED.includes(file.type)) {
setUploadError('Chỉ chấp nhận ảnh JPG hoặc PNG.');
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
const MAX_BYTES = 5 * 1024 * 1024;
if (file.size > MAX_BYTES) {
setUploadError('Ảnh vượt quá 5MB. Vui lòng chọn ảnh nhỏ hơn.');
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
// Simulate progress during local processing (FileReader is synchronous
// for small files; real uploads to MinIO would track XHR progress).
setUploadProgress(0);
const reader = new FileReader();
reader.onprogress = (ev) => {
if (ev.lengthComputable) {
setUploadProgress(Math.round((ev.loaded / ev.total) * 100));
}
};
reader.onload = (ev) => {
setImagePreview(ev.target?.result as string);
setUploadProgress(100);
// Hide the bar after a tick so users see "100%" briefly
setTimeout(() => setUploadProgress(null), 400);
};
reader.onerror = () => {
setUploadError('Không thể đọc ảnh. Vui lòng thử lại.');
setUploadProgress(null);
};
reader.readAsDataURL(file);
// In production, upload to server and get URL
// For now we store as data URL for preview purposes
// For now, use an object URL locally; presigned upload is a separate flow.
setImageUrl(URL.createObjectURL(file));
},
[],
@@ -104,6 +138,8 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
const handleClearImage = useCallback(() => {
setImagePreview(null);
setImageUrl(null);
setUploadError(null);
setUploadProgress(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
@@ -126,6 +162,15 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
description: data.description || undefined,
deepAnalysis: data.deepAnalysis,
imageUrl: imageUrl || undefined,
// AVM v2
useV2: data.useV2,
distanceToHospitalKm: toNum(data.distanceToHospitalKm),
distanceToParkKm: toNum(data.distanceToParkKm),
distanceToMallKm: toNum(data.distanceToMallKm),
floodZoneRisk: data.floodZoneRisk,
hasElevator: data.hasElevator,
hasParking: data.hasParking,
hasPool: data.hasPool,
});
};
@@ -350,13 +395,39 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/jpeg,image/png"
className="hidden"
onChange={handleImageChange}
/>
<p className="text-xs text-muted-foreground">
Tải nh bất đng sản đ AI phân tích trực quan (JPG, PNG, tối đa 5MB)
</p>
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">
Tải nh bất đng sản đ AI phân tích trực quan (JPG, PNG, tối đa 5MB)
</p>
{uploadProgress != null && (
<div
role="progressbar"
aria-label="Tiến độ tải ảnh"
aria-valuenow={uploadProgress}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
{uploadError && (
<p
role="alert"
data-testid="image-upload-error"
className="text-xs text-destructive"
>
{uploadError}
</p>
)}
</div>
</div>
</div>
@@ -396,6 +467,123 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
Phân tích chuyên sâu
</Label>
</div>
<div className="flex items-center gap-2">
<input
id="useV2"
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
{...register('useV2')}
/>
<Label htmlFor="useV2" className="flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-primary" />
hình v2 (Ensemble)
</Label>
</div>
</div>
{/* AVM v2 — Extended features (collapsible) */}
<div className="rounded-lg border bg-muted/30">
<button
type="button"
onClick={() => setShowV2Section(!showV2Section)}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
aria-expanded={showV2Section}
>
<span>Đc trưng mở rộng (AVM v2)</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${showV2Section ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{showV2Section && (
<div className="space-y-4 border-t p-4">
<p className="text-xs text-muted-foreground">
Các trường tùy chọn, chỉ sử dụng khi bật &ldquo; hình v2&rdquo;. Bỏ trống sẽ dùng giá trị mặc đnh.
</p>
{/* Distances */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="distanceToHospitalKm">Đến bệnh viện (km)</Label>
<Input
id="distanceToHospitalKm"
type="number"
step="0.1"
min="0"
{...register('distanceToHospitalKm')}
placeholder="1.5"
/>
</div>
<div className="space-y-2">
<Label htmlFor="distanceToParkKm">Đến công viên (km)</Label>
<Input
id="distanceToParkKm"
type="number"
step="0.1"
min="0"
{...register('distanceToParkKm')}
placeholder="0.8"
/>
</div>
<div className="space-y-2">
<Label htmlFor="distanceToMallKm">Đến TTTM (km)</Label>
<Input
id="distanceToMallKm"
type="number"
step="0.1"
min="0"
{...register('distanceToMallKm')}
placeholder="2.0"
/>
</div>
</div>
{/* Flood zone + amenity checkboxes */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="floodZoneRisk">Nguy ngập</Label>
<Select id="floodZoneRisk" {...register('floodZoneRisk')}>
<option value="">-- Không xác đnh --</option>
{FLOOD_RISK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
<div className="flex flex-col justify-center gap-2">
<div className="flex items-center gap-2">
<input
id="hasElevator"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasElevator')}
/>
<Label htmlFor="hasElevator"> thang máy</Label>
</div>
<div className="flex items-center gap-2">
<input
id="hasParking"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasParking')}
/>
<Label htmlFor="hasParking"> bãi đu xe</Label>
</div>
<div className="flex items-center gap-2">
<input
id="hasPool"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasPool')}
/>
<Label htmlFor="hasPool"> hồ bơi</Label>
</div>
</div>
</div>
</div>
)}
</div>
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">