From 79e173938b44d12f2cc56e984a6e6507206fc211 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 06:49:57 +0700 Subject: [PATCH] feat(avm): end-to-end AVM v2 schema + POST /analytics/valuation endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/modules/analytics/analytics.module.ts | 2 + .../predict-valuation.handler.ts | 62 ++++++ .../predict-valuation.query.ts | 33 +++ .../analytics/domain/services/avm-service.ts | 26 +++ .../services/ai-service.client.ts | 75 +++++++ .../services/http-avm.service.ts | 78 ++++++- .../controllers/analytics.controller.ts | 52 +++++ .../presentation/dto/predict-valuation.dto.ts | 174 +++++++++++++++ .../components/valuation/valuation-form.tsx | 204 +++++++++++++++++- apps/web/lib/validations/valuation.ts | 16 ++ apps/web/lib/valuation-api.ts | 31 ++- apps/web/tsconfig.tsbuildinfo | 2 +- 12 files changed, 736 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.query.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 029cd57..b19b8d3 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -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, diff --git a/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.handler.ts new file mode 100644 index 0000000..bd10b93 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.handler.ts @@ -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 { + constructor( + @Inject(AVM_SERVICE) private readonly avmService: IAVMService, + private readonly logger: LoggerService, + ) {} + + async execute(query: PredictValuationQuery): Promise { + 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.', + ); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.query.ts b/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.query.ts new file mode 100644 index 0000000..030d6b6 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/predict-valuation/predict-valuation.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/services/avm-service.ts b/apps/api/src/modules/analytics/domain/services/avm-service.ts index 12fe3d1..0b88e79 100644 --- a/apps/api/src/modules/analytics/domain/services/avm-service.ts +++ b/apps/api/src/modules/analytics/domain/services/avm-service.ts @@ -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 { diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts index 6c3ade1..9934b81 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -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; + predictV2(req: AiPredictV2Request): Promise; predictIndustrial(req: AiIndustrialPredictRequest): Promise; moderate(req: AiModerationRequest): Promise; scoreNeighborhood(req: AiNeighborhoodScoreRequest): Promise; @@ -146,6 +217,10 @@ export class AiServiceClient implements IAiServiceClient { return this.post('/avm/predict', req); } + async predictV2(req: AiPredictV2Request): Promise { + return this.post('/avm/v2/predict', req); + } + async predictIndustrial(req: AiIndustrialPredictRequest): Promise { return this.post('/avm/industrial/predict', req); } diff --git a/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts index ac47257..c328fe5 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts @@ -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 = { + 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>, + ): Promise { + 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 }, diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index 744004b..a436b82 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -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 { + 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) diff --git a/apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts b/apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts new file mode 100644 index 0000000..3404272 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/predict-valuation.dto.ts @@ -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; +} diff --git a/apps/web/components/valuation/valuation-form.tsx b/apps/web/components/valuation/valuation-form.tsx index 8a82344..086f46d 100644 --- a/apps/web/components/valuation/valuation-form.tsx +++ b/apps/web/components/valuation/valuation-form.tsx @@ -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(null); const [imageUrl, setImageUrl] = useState(null); + const [uploadProgress, setUploadProgress] = useState(null); + const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); + // v2 section collapsible state + const [showV2Section, setShowV2Section] = useState(false); + const handleProjectSearch = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setProjectQuery(value); @@ -85,17 +91,45 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) { const handleImageChange = useCallback( (e: React.ChangeEvent) => { 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) { -

- Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB) -

+
+

+ Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB) +

+ {uploadProgress != null && ( +
+
+
+ )} + {uploadError && ( +

+ {uploadError} +

+ )} +
@@ -396,6 +467,123 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) { Phân tích chuyên sâu + +
+ + +
+ + + {/* AVM v2 — Extended features (collapsible) */} +
+ + {showV2Section && ( +
+

+ 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. +

+ + {/* Distances */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Flood zone + amenity checkboxes */} +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )}