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
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:
@@ -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" />
|
||||
Mô 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 “Mô hình v2”. 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 cơ 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">Có 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">Có 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">Có hồ bơi</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
|
||||
|
||||
Reference in New Issue
Block a user