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

@@ -12,6 +12,7 @@ import { GetMarketReportHandler } from './application/queries/get-market-report/
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
import { PredictValuationHandler } from './application/queries/predict-valuation/predict-valuation.handler';
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationExplanationHandler } from './application/queries/valuation-explanation/valuation-explanation.handler';
import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler';
@@ -44,6 +45,7 @@ const QueryHandlers = [
GetPriceTrendHandler,
GetDistrictStatsHandler,
GetValuationHandler,
PredictValuationHandler,
BatchValuationHandler,
ValuationHistoryHandler,
ValuationComparisonHandler,

View File

@@ -0,0 +1,62 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService } from '@modules/shared';
import {
AVM_SERVICE,
type IAVMService,
type ValuationResult,
} from '../../../domain/services/avm-service';
import { PredictValuationQuery } from './predict-valuation.query';
export type PredictValuationDto = ValuationResult;
@QueryHandler(PredictValuationQuery)
export class PredictValuationHandler implements IQueryHandler<PredictValuationQuery> {
constructor(
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
private readonly logger: LoggerService,
) {}
async execute(query: PredictValuationQuery): Promise<PredictValuationDto> {
try {
return await this.avmService.estimateValue({
propertyId: undefined,
propertyType: query.propertyType,
areaM2: query.area,
district: query.district,
city: query.city,
bedrooms: query.bedrooms,
bathrooms: query.bathrooms,
floors: query.floors,
frontage: query.frontage,
roadWidth: query.roadWidth,
yearBuilt: query.yearBuilt,
hasLegalPaper: query.hasLegalPaper,
latitude: query.latitude,
longitude: query.longitude,
projectId: query.projectId,
imageUrl: query.imageUrl,
description: query.description,
deepAnalysis: query.deepAnalysis,
useV2: query.useV2,
distanceToHospitalKm: query.distanceToHospitalKm,
distanceToParkKm: query.distanceToParkKm,
distanceToMallKm: query.distanceToMallKm,
floodZoneRisk: query.floodZoneRisk,
hasElevator: query.hasElevator,
hasParking: query.hasParking,
hasPool: query.hasPool,
});
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Predict valuation failed: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException(
'Không thể định giá bất động sản. Vui lòng thử lại sau.',
);
}
}
}

View File

@@ -0,0 +1,33 @@
import { type PropertyType } from '@prisma/client';
import { type FloodZoneRisk } from '../../../domain/services/avm-service';
export class PredictValuationQuery {
constructor(
public readonly userId: string | null,
public readonly propertyType: PropertyType,
public readonly area: number,
public readonly district: string,
public readonly city: string,
public readonly bedrooms?: number,
public readonly bathrooms?: number,
public readonly floors?: number,
public readonly frontage?: number,
public readonly roadWidth?: number,
public readonly yearBuilt?: number,
public readonly hasLegalPaper?: boolean,
public readonly latitude?: number,
public readonly longitude?: number,
public readonly projectId?: string,
public readonly imageUrl?: string,
public readonly description?: string,
public readonly deepAnalysis?: boolean,
public readonly useV2?: boolean,
public readonly distanceToHospitalKm?: number,
public readonly distanceToParkKm?: number,
public readonly distanceToMallKm?: number,
public readonly floodZoneRisk?: FloodZoneRisk,
public readonly hasElevator?: boolean,
public readonly hasParking?: boolean,
public readonly hasPool?: boolean,
) {}
}

View File

@@ -2,6 +2,8 @@ import { type PropertyType } from '@prisma/client';
export const AVM_SERVICE = Symbol('AVM_SERVICE');
export type FloodZoneRisk = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
export interface AVMParams {
propertyId?: string;
latitude?: number;
@@ -11,6 +13,30 @@ export interface AVMParams {
yearBuilt?: number;
floor?: number;
totalFloors?: number;
// ── Optional inline descriptors (used when no propertyId is given) ──
district?: string;
city?: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
roadWidth?: number;
hasLegalPaper?: boolean;
projectId?: string;
imageUrl?: string;
description?: string;
deepAnalysis?: boolean;
// ── AVM v2 features ────────────────────────────────────────────────
useV2?: boolean;
distanceToHospitalKm?: number;
distanceToParkKm?: number;
distanceToMallKm?: number;
floodZoneRisk?: FloodZoneRisk;
hasElevator?: boolean;
hasParking?: boolean;
hasPool?: boolean;
}
export interface Comparable {

View File

@@ -23,6 +23,76 @@ export interface AiPredictResponse {
price_range_high: number;
}
/**
* AVM v2 request — extended feature set for residential ensemble.
* Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py.
*/
export interface AiPredictV2Request {
district: string;
city: string;
property_type: string;
area_m2: number;
distance_to_cbd_km?: number;
distance_to_metro_km?: number;
distance_to_school_km?: number;
distance_to_hospital_km?: number;
distance_to_park_km?: number;
distance_to_mall_km?: number;
flood_zone_risk?: number;
neighborhood_score?: number;
rooms?: number;
floor_level?: number;
total_floors?: number;
direction?: string;
floor_ratio?: number;
building_age_years?: number;
has_elevator?: boolean;
has_parking?: boolean;
has_pool?: boolean;
has_legal_paper?: boolean;
developer_reputation?: number;
avg_price_district_3m_vnd_m2?: number;
listing_density?: number;
absorption_rate?: number;
dom_avg?: number;
price_momentum_30d?: number;
yoy_change?: number;
renovation_score?: number;
view_quality?: number;
interior_quality?: number;
noise_level?: number;
natural_light?: number;
month?: number;
quarter?: number;
is_year_end?: boolean;
}
export interface AiPredictV2FeatureImportance {
feature: string;
importance: number;
}
export interface AiPredictV2Comparable {
district: string;
property_type: string;
area_m2: number;
price_vnd: number;
price_per_m2_vnd: number;
similarity_score: number;
}
export interface AiPredictV2Response {
estimated_price_vnd: number;
confidence: number;
price_per_m2_vnd: number;
price_range_low_vnd: number;
price_range_high_vnd: number;
drivers?: AiPredictV2FeatureImportance[];
comparables?: AiPredictV2Comparable[];
model_version?: string;
ensemble_method?: string;
}
export interface AiIndustrialPredictRequest {
province: string;
region: string;
@@ -124,6 +194,7 @@ export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
export interface IAiServiceClient {
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response>;
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise<AiNeighborhoodScoreResponse>;
@@ -146,6 +217,10 @@ export class AiServiceClient implements IAiServiceClient {
return this.post<AiPredictResponse>('/avm/predict', req);
}
async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> {
return this.post<AiPredictV2Response>('/avm/v2/predict', req);
}
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
}

View File

@@ -12,7 +12,16 @@ import {
AI_SERVICE_CLIENT,
type IAiServiceClient,
type AiPredictRequest,
type AiPredictV2Request,
} from './ai-service.client';
/** Map string risk buckets to the 0..1 float the Python service expects. */
const FLOOD_RISK_TO_SCORE: Record<string, number> = {
NONE: 0,
LOW: 0.33,
MEDIUM: 0.66,
HIGH: 1,
};
import { PrismaAVMService } from './prisma-avm.service';
/** Max concurrency for batch AI calls to avoid overloading the Python service. */
@@ -83,18 +92,22 @@ export class HttpAVMService implements IAVMService {
? await this.getPropertyDetails(params.propertyId)
: null;
if (params.useV2) {
return this.estimateViaAiV2(params, propertyData);
}
const request: AiPredictRequest = {
area: params.areaM2 ?? propertyData?.areaM2 ?? 0,
district: propertyData?.district ?? '',
city: propertyData?.city ?? '',
district: params.district ?? propertyData?.district ?? '',
city: params.city ?? propertyData?.city ?? '',
property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(),
bedrooms: propertyData?.bedrooms ?? 0,
bathrooms: propertyData?.bathrooms ?? 0,
floors: propertyData?.floors ?? 0,
frontage: 0,
road_width: 0,
bedrooms: params.bedrooms ?? propertyData?.bedrooms ?? 0,
bathrooms: params.bathrooms ?? propertyData?.bathrooms ?? 0,
floors: params.floors ?? propertyData?.floors ?? 0,
frontage: params.frontage ?? 0,
road_width: params.roadWidth ?? 0,
year_built: params.yearBuilt ?? propertyData?.yearBuilt,
has_legal_paper: propertyData?.hasLegalPaper ?? true,
has_legal_paper: params.hasLegalPaper ?? propertyData?.hasLegalPaper ?? true,
};
const aiResult = await this.aiClient.predict(request);
@@ -118,6 +131,55 @@ export class HttpAVMService implements IAVMService {
};
}
private async estimateViaAiV2(
params: AVMParams,
propertyData: Awaited<ReturnType<HttpAVMService['getPropertyDetails']>>,
): Promise<ValuationResult> {
const yearBuilt = params.yearBuilt ?? propertyData?.yearBuilt ?? null;
const now = new Date();
const v2Request: AiPredictV2Request = {
district: params.district ?? propertyData?.district ?? '',
city: params.city ?? propertyData?.city ?? '',
property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(),
area_m2: params.areaM2 ?? propertyData?.areaM2 ?? 0,
distance_to_hospital_km: params.distanceToHospitalKm,
distance_to_park_km: params.distanceToParkKm,
distance_to_mall_km: params.distanceToMallKm,
flood_zone_risk:
params.floodZoneRisk != null ? FLOOD_RISK_TO_SCORE[params.floodZoneRisk] ?? 0 : undefined,
rooms: params.bedrooms ?? propertyData?.bedrooms,
total_floors: params.floors ?? propertyData?.floors,
building_age_years: yearBuilt != null ? Math.max(0, now.getFullYear() - yearBuilt) : undefined,
has_elevator: params.hasElevator,
has_parking: params.hasParking,
has_pool: params.hasPool,
has_legal_paper: params.hasLegalPaper ?? propertyData?.hasLegalPaper ?? true,
month: now.getMonth() + 1,
quarter: Math.floor(now.getMonth() / 3) + 1,
is_year_end: now.getMonth() >= 9,
};
const aiResult = await this.aiClient.predictV2(v2Request);
let comparables: Comparable[] = [];
try {
if (params.propertyId) {
comparables = await this.fallback.getComparables(params.propertyId, 2000);
}
} catch {
// Supplementary — don't fail
}
return {
estimatedPrice: Math.round(aiResult.estimated_price_vnd).toString(),
confidence: aiResult.confidence,
pricePerM2: Math.round(aiResult.price_per_m2_vnd),
comparables,
modelVersion: aiResult.model_version ?? 'ai-service-v2',
};
}
private async getPropertyDetails(propertyId: string) {
const row = await this.prisma.property.findUnique({
where: { id: propertyId },

View File

@@ -25,6 +25,8 @@ import { type PriceTrendDto } from '../../application/queries/get-price-trend/ge
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
import { type PredictValuationDto } from '../../application/queries/predict-valuation/predict-valuation.handler';
import { PredictValuationQuery } from '../../application/queries/predict-valuation/predict-valuation.query';
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
@@ -36,6 +38,7 @@ import { GetHeatmapDto } from '../dto/get-heatmap.dto';
import { GetMarketReportDto } from '../dto/get-market-report.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { GetValuationDto } from '../dto/get-valuation.dto';
import { PredictValuationDto as PredictValuationBodyDto } from '../dto/predict-valuation.dto';
import { ValuationComparisonDto } from '../dto/valuation-comparison.dto';
import { ValuationHistoryDto } from '../dto/valuation-history.dto';
@@ -112,6 +115,55 @@ export class AnalyticsController {
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Post('valuation')
@ApiOperation({
summary: 'Định giá BĐS (AVM v1/v2) từ form nhập tay',
description:
'Nhận đặc trưng BĐS qua body và trả về ước tính giá. `useV2=true` dùng mô hình ensemble v2 với các trường khoảng cách hạ tầng + tiện nghi + nguy cơ ngập.',
})
@ApiResponse({ status: 200, description: 'Valuation estimate retrieved' })
@ApiResponse({ status: 400, description: 'Invalid parameters' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
@ApiResponse({ status: 503, description: 'Dịch vụ AI tạm thời không khả dụng' })
async predictValuation(@Body() dto: PredictValuationBodyDto): Promise<PredictValuationDto> {
return this.queryBus.execute(
new PredictValuationQuery(
null,
dto.propertyType,
dto.area,
dto.district,
dto.city,
dto.bedrooms,
dto.bathrooms,
dto.floors,
dto.frontage,
dto.roadWidth,
dto.yearBuilt,
dto.hasLegalPaper,
dto.latitude,
dto.longitude,
dto.projectId,
dto.imageUrl,
dto.description,
dto.deepAnalysis,
dto.useV2,
dto.distanceToHospitalKm,
dto.distanceToParkKm,
dto.distanceToMallKm,
dto.floodZoneRisk,
dto.hasElevator,
dto.hasParking,
dto.hasPool,
),
);
}
@ApiBearerAuth('JWT')
@EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard)

View File

@@ -0,0 +1,174 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export type FloodZoneRisk = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
export const FLOOD_ZONE_RISK_VALUES: FloodZoneRisk[] = ['NONE', 'LOW', 'MEDIUM', 'HIGH'];
export class PredictValuationDto {
// ── Core v1 fields ─────────────────────────────────────────────────
@ApiProperty({ enum: PropertyType, description: 'Loại bất động sản' })
@IsEnum(PropertyType)
propertyType!: PropertyType;
@ApiProperty({ description: 'Diện tích (m²)', example: 75 })
@IsNumber()
@Min(1)
@Type(() => Number)
area!: number;
@ApiProperty({ description: 'Quận/Huyện', example: 'Quận 1' })
@IsString()
district!: string;
@ApiProperty({ description: 'Tỉnh/Thành phố', example: 'Hồ Chí Minh' })
@IsString()
city!: string;
@ApiPropertyOptional({ description: 'Số phòng ngủ', example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(20)
@Type(() => Number)
bedrooms?: number;
@ApiPropertyOptional({ description: 'Số phòng tắm' })
@IsOptional()
@IsInt()
@Min(0)
@Max(20)
@Type(() => Number)
bathrooms?: number;
@ApiPropertyOptional({ description: 'Số tầng' })
@IsOptional()
@IsInt()
@Min(0)
@Max(200)
@Type(() => Number)
floors?: number;
@ApiPropertyOptional({ description: 'Mặt tiền (m)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
frontage?: number;
@ApiPropertyOptional({ description: 'Độ rộng đường trước nhà (m)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
roadWidth?: number;
@ApiPropertyOptional({ description: 'Năm xây' })
@IsOptional()
@IsInt()
@Min(1900)
@Max(2100)
@Type(() => Number)
yearBuilt?: number;
@ApiPropertyOptional({ description: 'Có giấy tờ pháp lý đầy đủ' })
@IsOptional()
@IsBoolean()
hasLegalPaper?: boolean;
@ApiPropertyOptional({ description: 'Vĩ độ' })
@IsOptional()
@IsNumber()
@Type(() => Number)
latitude?: number;
@ApiPropertyOptional({ description: 'Kinh độ' })
@IsOptional()
@IsNumber()
@Type(() => Number)
longitude?: number;
@ApiPropertyOptional({ description: 'ID dự án (nếu thuộc dự án)' })
@IsOptional()
@IsString()
projectId?: string;
@ApiPropertyOptional({ description: 'URL ảnh BĐS để phân tích' })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiPropertyOptional({ description: 'Mô tả bổ sung' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: 'Bật phân tích chuyên sâu (Claude)' })
@IsOptional()
@IsBoolean()
deepAnalysis?: boolean;
// ── AVM v2 fields ──────────────────────────────────────────────────
@ApiPropertyOptional({ description: 'Dùng mô hình AVM v2 (ensemble + đặc trưng mở rộng)' })
@IsOptional()
@IsBoolean()
useV2?: boolean;
@ApiPropertyOptional({ description: 'Khoảng cách tới bệnh viện (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToHospitalKm?: number;
@ApiPropertyOptional({ description: 'Khoảng cách tới công viên (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToParkKm?: number;
@ApiPropertyOptional({ description: 'Khoảng cách tới trung tâm thương mại (km)' })
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
distanceToMallKm?: number;
@ApiPropertyOptional({
enum: FLOOD_ZONE_RISK_VALUES,
description: 'Mức độ nguy cơ ngập lụt',
})
@IsOptional()
@IsIn(FLOOD_ZONE_RISK_VALUES)
floodZoneRisk?: FloodZoneRisk;
@ApiPropertyOptional({ description: 'Có thang máy' })
@IsOptional()
@IsBoolean()
hasElevator?: boolean;
@ApiPropertyOptional({ description: 'Có chỗ đậu xe' })
@IsOptional()
@IsBoolean()
hasParking?: boolean;
@ApiPropertyOptional({ description: 'Có hồ bơi' })
@IsOptional()
@IsBoolean()
hasPool?: boolean;
}

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">

View File

@@ -15,6 +15,13 @@ export const CITIES = [
{ value: 'Da Nang', label: 'Đà Nẵng' },
] as const;
export const FLOOD_RISK_OPTIONS = [
{ value: 'NONE', label: 'Không có' },
{ value: 'LOW', label: 'Thấp' },
{ value: 'MEDIUM', label: 'Trung bình' },
{ value: 'HIGH', label: 'Cao' },
] as const;
export const valuationFormSchema = z.object({
propertyType: z.string().min(1, 'Vui lòng chọn loại bất động sản'),
area: z.string().min(1, 'Vui lòng nhập diện tích').refine(
@@ -34,6 +41,15 @@ export const valuationFormSchema = z.object({
projectId: z.string().optional(),
description: z.string().optional(),
deepAnalysis: z.boolean().optional(),
/** AVM v2 fields */
useV2: z.boolean().optional(),
distanceToHospitalKm: z.string().optional(),
distanceToParkKm: z.string().optional(),
distanceToMallKm: z.string().optional(),
floodZoneRisk: z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH']).optional(),
hasElevator: z.boolean().optional(),
hasParking: z.boolean().optional(),
hasPool: z.boolean().optional(),
});
export type ValuationFormData = z.infer<typeof valuationFormSchema>;

View File

@@ -2,6 +2,15 @@ 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;
@@ -24,6 +33,15 @@ export interface ValuationRequest {
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 {
@@ -157,10 +175,9 @@ export interface ProjectSuggestion {
export const valuationApi = {
/** Request AVM estimate via POST /analytics/valuation */
predict: (data: ValuationRequest) => {
// Build request body with all fields
const body: Record<string, unknown> = {
propertyType: data.propertyType,
areaM2: data.area,
area: data.area,
district: data.district,
city: data.city,
};
@@ -179,6 +196,16 @@ export const valuationApi = {
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);
},

File diff suppressed because one or more lines are too long