diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 36f62cd..864829a 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -4,11 +4,14 @@ import { GenerateReportHandler } from './application/commands/generate-report/ge import { TrackEventHandler } from './application/commands/track-event/track-event.handler'; import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler'; import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler'; +import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler'; import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler'; import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler'; import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler'; import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler'; +import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler'; +import { ValuationHistoryHandler } from './application/queries/valuation-history/valuation-history.handler'; import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository'; import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository'; import { AVM_SERVICE } from './domain/services/avm-service'; @@ -32,6 +35,9 @@ const QueryHandlers = [ GetPriceTrendHandler, GetDistrictStatsHandler, GetValuationHandler, + BatchValuationHandler, + ValuationHistoryHandler, + ValuationComparisonHandler, ]; const EventHandlers = [ diff --git a/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts new file mode 100644 index 0000000..6757615 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts @@ -0,0 +1,48 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared'; +import { + AVM_SERVICE, + type IAVMService, + type BatchValuationResult, +} from '../../../domain/services/avm-service'; +import { BatchValuationQuery } from './batch-valuation.query'; + +export type BatchValuationDto = BatchValuationResult[]; + +@QueryHandler(BatchValuationQuery) +export class BatchValuationHandler implements IQueryHandler { + constructor( + @Inject(AVM_SERVICE) private readonly avmService: IAVMService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: BatchValuationQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.VALUATION, + 'batch', + ...query.propertyIds.slice().sort(), + ); + + return this.cache.getOrSet( + cacheKey, + async () => { + const items = query.propertyIds.map((propertyId) => ({ propertyId })); + return this.avmService.estimateBatch(items); + }, + CacheTTL.MARKET_DATA, + 'batch_valuation', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Batch 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á hàng loạt. Vui lòng thử lại sau.'); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.query.ts b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.query.ts new file mode 100644 index 0000000..ce8acd5 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.query.ts @@ -0,0 +1,5 @@ +export class BatchValuationQuery { + constructor( + public readonly propertyIds: string[], + ) {} +} diff --git a/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts new file mode 100644 index 0000000..b02fbac --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts @@ -0,0 +1,143 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService, type PrismaService } from '@modules/shared'; +import { + AVM_SERVICE, + type IAVMService, + type ValuationComparisonItem, + type ValuationResult, +} from '../../../domain/services/avm-service'; +import { generateConfidenceExplanation } from '../../../infrastructure/services/confidence-explanation.helper'; +import { ValuationComparisonQuery } from './valuation-comparison.query'; + +export interface ValuationComparisonDto { + properties: ValuationComparisonItem[]; + summary: { + highestValue: { propertyId: string; estimatedPrice: string } | null; + lowestValue: { propertyId: string; estimatedPrice: string } | null; + averagePricePerM2: number; + averageConfidence: number; + }; +} + +@QueryHandler(ValuationComparisonQuery) +export class ValuationComparisonHandler implements IQueryHandler { + constructor( + @Inject(AVM_SERVICE) private readonly avmService: IAVMService, + private readonly prisma: PrismaService, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: ValuationComparisonQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.VALUATION, + 'compare', + ...query.propertyIds.slice().sort(), + ); + + return this.cache.getOrSet( + cacheKey, + () => this.buildComparison(query.propertyIds), + CacheTTL.MARKET_DATA, + 'valuation_comparison', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Valuation comparison failed: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể so sánh định giá. Vui lòng thử lại sau.'); + } + } + + private async buildComparison(propertyIds: string[]): Promise { + // Fetch property details and valuations in parallel + const [properties, valuations] = await Promise.all([ + this.prisma.property.findMany({ + where: { id: { in: propertyIds } }, + select: { + id: true, + address: true, + district: true, + areaM2: true, + propertyType: true, + }, + }), + this.fetchValuations(propertyIds), + ]); + + const propertyMap = new Map(properties.map((p) => [p.id, p])); + const valuationMap = new Map(valuations.map((v) => [v.propertyId, v.valuation])); + + const items: ValuationComparisonItem[] = propertyIds.map((propertyId) => { + const prop = propertyMap.get(propertyId); + const valuation = valuationMap.get(propertyId) ?? null; + + // Add confidence explanation if we have a valuation + const enrichedValuation = valuation + ? { ...valuation, confidenceExplanation: generateConfidenceExplanation(valuation.confidence, valuation.comparables.length) } + : null; + + return { + propertyId, + address: prop?.address ?? '', + district: prop?.district ?? '', + areaM2: prop?.areaM2 ?? 0, + propertyType: prop?.propertyType ?? 'APARTMENT', + valuation: enrichedValuation, + }; + }); + + // Calculate summary + const validValuations = items.filter((i) => i.valuation !== null); + const prices = validValuations.map((i) => ({ + propertyId: i.propertyId, + price: BigInt(i.valuation!.estimatedPrice), + priceStr: i.valuation!.estimatedPrice, + })); + + let highestValue: { propertyId: string; estimatedPrice: string } | null = null; + let lowestValue: { propertyId: string; estimatedPrice: string } | null = null; + + if (prices.length > 0) { + const sorted = prices.sort((a, b) => (a.price > b.price ? 1 : a.price < b.price ? -1 : 0)); + const highest = sorted[sorted.length - 1]!; + const lowest = sorted[0]!; + highestValue = { propertyId: highest.propertyId, estimatedPrice: highest.priceStr }; + lowestValue = { propertyId: lowest.propertyId, estimatedPrice: lowest.priceStr }; + } + + const averagePricePerM2 = validValuations.length > 0 + ? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.pricePerM2, 0) / validValuations.length) + : 0; + + const averageConfidence = validValuations.length > 0 + ? Math.round(validValuations.reduce((sum, i) => sum + i.valuation!.confidence, 0) / validValuations.length * 100) / 100 + : 0; + + return { + properties: items, + summary: { highestValue, lowestValue, averagePricePerM2, averageConfidence }, + }; + } + + private async fetchValuations(propertyIds: string[]): Promise<{ propertyId: string; valuation: ValuationResult | null }[]> { + const results = await Promise.allSettled( + propertyIds.map(async (propertyId) => { + const valuation = await this.avmService.estimateValue({ propertyId }); + return { propertyId, valuation }; + }), + ); + + return results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } + return { propertyId: propertyIds[index]!, valuation: null }; + }); + } +} diff --git a/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.query.ts b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.query.ts new file mode 100644 index 0000000..82d5025 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.query.ts @@ -0,0 +1,5 @@ +export class ValuationComparisonQuery { + constructor( + public readonly propertyIds: string[], + ) {} +} diff --git a/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts new file mode 100644 index 0000000..100e18f --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts @@ -0,0 +1,67 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL, DomainException, type LoggerService } from '@modules/shared'; +import { + VALUATION_REPOSITORY, + type IValuationRepository, +} from '../../../domain/repositories/valuation.repository'; +import { type ValuationHistoryPoint } from '../../../domain/services/avm-service'; +import { ValuationHistoryQuery } from './valuation-history.query'; + +export interface ValuationHistoryDto { + propertyId: string; + history: ValuationHistoryPoint[]; + totalRecords: number; +} + +@QueryHandler(ValuationHistoryQuery) +export class ValuationHistoryHandler implements IQueryHandler { + constructor( + @Inject(VALUATION_REPOSITORY) private readonly valuationRepo: IValuationRepository, + private readonly cache: CacheService, + private readonly logger: LoggerService, + ) {} + + async execute(query: ValuationHistoryQuery): Promise { + try { + const cacheKey = CacheService.buildKey( + CachePrefix.VALUATION, + 'history', + query.propertyId, + query.limit.toString(), + ); + + return this.cache.getOrSet( + cacheKey, + async () => { + const entities = await this.valuationRepo.findByPropertyId(query.propertyId); + const limited = entities.slice(0, query.limit); + + const history: ValuationHistoryPoint[] = limited.map((entity) => ({ + estimatedPrice: entity.estimatedPrice.toString(), + confidence: entity.confidence, + pricePerM2: entity.pricePerM2, + modelVersion: entity.modelVersion, + valuedAt: entity.createdAt.toISOString(), + })); + + return { + propertyId: query.propertyId, + history, + totalRecords: entities.length, + }; + }, + CacheTTL.DISTRICT_STATS, + 'valuation_history', + ); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Valuation history failed for property ${query.propertyId}: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể lấy lịch sử định giá. Vui lòng thử lại sau.'); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.query.ts b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.query.ts new file mode 100644 index 0000000..221f7f3 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.query.ts @@ -0,0 +1,6 @@ +export class ValuationHistoryQuery { + constructor( + public readonly propertyId: string, + public readonly limit: number = 50, + ) {} +} 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 f1afa5d..12fe3d1 100644 --- a/apps/api/src/modules/analytics/domain/services/avm-service.ts +++ b/apps/api/src/modules/analytics/domain/services/avm-service.ts @@ -1,4 +1,4 @@ -import { PropertyType } from '@prisma/client'; +import { type PropertyType } from '@prisma/client'; export const AVM_SERVICE = Symbol('AVM_SERVICE'); @@ -31,9 +31,38 @@ export interface ValuationResult { pricePerM2: number; comparables: Comparable[]; modelVersion: string; + confidenceExplanation?: string; +} + +export interface BatchValuationItem { + propertyId: string; +} + +export interface BatchValuationResult { + propertyId: string; + valuation: ValuationResult | null; + error?: string; +} + +export interface ValuationHistoryPoint { + estimatedPrice: string; + confidence: number; + pricePerM2: number; + modelVersion: string; + valuedAt: string; +} + +export interface ValuationComparisonItem { + propertyId: string; + address: string; + district: string; + areaM2: number; + propertyType: PropertyType; + valuation: ValuationResult | null; } export interface IAVMService { estimateValue(params: AVMParams): Promise; getComparables(propertyId: string, radiusMeters: number): Promise; + estimateBatch(items: BatchValuationItem[]): Promise; } diff --git a/apps/api/src/modules/analytics/infrastructure/services/confidence-explanation.helper.ts b/apps/api/src/modules/analytics/infrastructure/services/confidence-explanation.helper.ts new file mode 100644 index 0000000..f1d61d2 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/confidence-explanation.helper.ts @@ -0,0 +1,45 @@ +/** + * Generates a human-readable Vietnamese explanation of the AVM confidence score. + * + * The explanation considers: + * - Overall confidence level (high/medium/low) + * - Number of comparable properties used + * - General market data quality + */ +export function generateConfidenceExplanation( + confidence: number, + comparableCount: number, +): string { + const parts: string[] = []; + + // Confidence level description + if (confidence >= 0.8) { + parts.push('Mức độ tin cậy cao'); + } else if (confidence >= 0.5) { + parts.push('Mức độ tin cậy trung bình'); + } else if (confidence > 0) { + parts.push('Mức độ tin cậy thấp'); + } else { + return 'Không đủ dữ liệu để đưa ra ước tính đáng tin cậy. Kết quả chỉ mang tính tham khảo.'; + } + + parts.push(`(${Math.round(confidence * 100)}%).`); + + // Comparable properties context + if (comparableCount >= 10) { + parts.push(`Dựa trên ${comparableCount} bất động sản tương đương trong khu vực, cung cấp cơ sở dữ liệu vững chắc.`); + } else if (comparableCount >= 5) { + parts.push(`Dựa trên ${comparableCount} bất động sản tương đương. Dữ liệu đủ để ước tính hợp lý.`); + } else if (comparableCount >= 3) { + parts.push(`Chỉ có ${comparableCount} bất động sản tương đương. Kết quả có thể dao động.`); + } else { + parts.push('Số lượng bất động sản tương đương hạn chế. Nên tham khảo thêm các nguồn khác.'); + } + + // Additional guidance based on confidence + if (confidence < 0.5) { + parts.push('Khuyến nghị: Nên tham vấn chuyên gia định giá để có kết quả chính xác hơn.'); + } + + return parts.join(' '); +} 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 1891ad7..744d741 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 @@ -1,17 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; -import { PrismaService, LoggerService } from '@modules/shared'; +import { type PrismaService, type LoggerService } from '@modules/shared'; import { - IAVMService, + type IAVMService, type AVMParams, type ValuationResult, type Comparable, + type BatchValuationItem, + type BatchValuationResult, } from '../../domain/services/avm-service'; import { AI_SERVICE_CLIENT, - IAiServiceClient, + type IAiServiceClient, type AiPredictRequest, } from './ai-service.client'; -import { PrismaAVMService } from './prisma-avm.service'; +import { type PrismaAVMService } from './prisma-avm.service'; + +/** Max concurrency for batch AI calls to avoid overloading the Python service. */ +const BATCH_CONCURRENCY = 5; @Injectable() export class HttpAVMService implements IAVMService { @@ -38,6 +43,41 @@ export class HttpAVMService implements IAVMService { return this.fallback.getComparables(propertyId, radiusMeters); } + async estimateBatch(items: BatchValuationItem[]): Promise { + const results: BatchValuationResult[] = []; + + // Process in batches with limited concurrency + for (let i = 0; i < items.length; i += BATCH_CONCURRENCY) { + const chunk = items.slice(i, i + BATCH_CONCURRENCY); + const chunkResults = await Promise.allSettled( + chunk.map(async (item) => { + const valuation = await this.estimateValue({ propertyId: item.propertyId }); + return { propertyId: item.propertyId, valuation } as BatchValuationResult; + }), + ); + + for (let j = 0; j < chunkResults.length; j++) { + const result = chunkResults[j]!; + const item = chunk[j]!; + if (result.status === 'fulfilled') { + results.push(result.value); + } else { + this.logger.warn( + `Batch valuation failed for property ${item.propertyId}: ${String(result.reason)}`, + 'HttpAVMService', + ); + results.push({ + propertyId: item.propertyId, + valuation: null, + error: result.reason instanceof Error ? result.reason.message : 'Lỗi định giá', + }); + } + } + } + + return results; + } + private async estimateViaAi(params: AVMParams): Promise { const propertyData = params.propertyId ? await this.getPropertyDetails(params.propertyId) diff --git a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts index 170d2c5..947addd 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -1,11 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { PropertyType } from '@prisma/client'; -import { PrismaService } from '@modules/shared'; +import { type PropertyType } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; import { - IAVMService, + type IAVMService, type AVMParams, type ValuationResult, type Comparable, + type BatchValuationItem, + type BatchValuationResult, } from '../../domain/services/avm-service'; import { type RawComparable, @@ -68,6 +70,19 @@ export class PrismaAVMService implements IAVMService { return raws.map(toComparableDto); } + async estimateBatch(items: BatchValuationItem[]): Promise { + return Promise.all( + items.map(async (item) => { + try { + const valuation = await this.estimateValue({ propertyId: item.propertyId }); + return { propertyId: item.propertyId, valuation }; + } catch { + return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' }; + } + }), + ); + } + private async resolveParams(params: AVMParams): Promise<{ lat: number; lng: number; areaM2: number; propertyType: PropertyType | undefined; 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 e6183ed..1d8a30d 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -1,28 +1,41 @@ import { + Body, Controller, Get, + Param, + Post, Query, UseGuards, } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { type QueryBus } from '@nestjs/cqrs'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; import { JwtAuthGuard } from '@modules/auth'; +import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; -import { DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler'; +import { type BatchValuationDto as BatchValuationQueryDto } from '../../application/queries/batch-valuation/batch-valuation.handler'; +import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; +import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler'; import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query'; -import { HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; +import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query'; -import { MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler'; +import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler'; import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query'; -import { PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler'; +import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler'; import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query'; -import { ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler'; +import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler'; import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query'; -import { GetDistrictStatsDto } from '../dto/get-district-stats.dto'; -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 { 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'; +import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; +import { type BatchValuationDto } from '../dto/batch-valuation.dto'; +import { type GetDistrictStatsDto } from '../dto/get-district-stats.dto'; +import { type GetHeatmapDto } from '../dto/get-heatmap.dto'; +import { type GetMarketReportDto } from '../dto/get-market-report.dto'; +import { type GetPriceTrendDto } from '../dto/get-price-trend.dto'; +import { type GetValuationDto } from '../dto/get-valuation.dto'; +import { type ValuationComparisonDto } from '../dto/valuation-comparison.dto'; +import { type ValuationHistoryDto } from '../dto/valuation-history.dto'; @ApiTags('analytics') @Controller('analytics') @@ -96,4 +109,53 @@ export class AnalyticsController { new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType), ); } + + @ApiBearerAuth('JWT') + @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) + @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Post('valuation/batch') + @ApiOperation({ summary: 'Batch valuation for multiple properties (max 50)' }) + @ApiResponse({ status: 200, description: 'Batch valuation results retrieved' }) + @ApiResponse({ status: 400, description: 'Invalid parameters' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + async batchValuation(@Body() dto: BatchValuationDto): Promise { + return this.queryBus.execute( + new BatchValuationQuery(dto.propertyIds), + ); + } + + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('valuation/history/:propertyId') + @ApiOperation({ summary: 'Get valuation history for a property (chart data)' }) + @ApiParam({ name: 'propertyId', description: 'Property ID' }) + @ApiResponse({ status: 200, description: 'Valuation history retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getValuationHistory( + @Param('propertyId') propertyId: string, + @Query() dto: ValuationHistoryDto, + ): Promise { + return this.queryBus.execute( + new ValuationHistoryQuery(propertyId, dto.limit ?? 50), + ); + } + + @ApiBearerAuth('JWT') + @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) + @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Post('valuation/compare') + @ApiOperation({ summary: 'Compare valuations for 2-5 properties side by side' }) + @ApiResponse({ status: 200, description: 'Valuation comparison retrieved' }) + @ApiResponse({ status: 400, description: 'Invalid parameters — provide 2-5 property IDs' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + async compareValuations(@Body() dto: ValuationComparisonDto): Promise { + return this.queryBus.execute( + new ValuationComparisonQuery(dto.propertyIds), + ); + } } diff --git a/apps/api/src/modules/analytics/presentation/dto/batch-valuation.dto.ts b/apps/api/src/modules/analytics/presentation/dto/batch-valuation.dto.ts new file mode 100644 index 0000000..cd95a73 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/batch-valuation.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator'; + +export class BatchValuationDto { + @ApiProperty({ + description: 'Array of property IDs to valuate (max 50)', + example: ['prop-1', 'prop-2'], + type: [String], + }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(50) + @IsString({ each: true }) + @Transform(({ value }) => (Array.isArray(value) ? value : [value])) + propertyIds!: string[]; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts index e510b69..e695c10 100644 --- a/apps/api/src/modules/analytics/presentation/dto/index.ts +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -3,3 +3,6 @@ export { GetHeatmapDto } from './get-heatmap.dto'; export { GetPriceTrendDto } from './get-price-trend.dto'; export { GetDistrictStatsDto } from './get-district-stats.dto'; export { GetValuationDto } from './get-valuation.dto'; +export { BatchValuationDto } from './batch-valuation.dto'; +export { ValuationHistoryDto } from './valuation-history.dto'; +export { ValuationComparisonDto } from './valuation-comparison.dto'; diff --git a/apps/api/src/modules/analytics/presentation/dto/valuation-comparison.dto.ts b/apps/api/src/modules/analytics/presentation/dto/valuation-comparison.dto.ts new file mode 100644 index 0000000..a2a3bcf --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/valuation-comparison.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator'; + +export class ValuationComparisonDto { + @ApiProperty({ + description: 'Array of property IDs to compare (2-5 properties)', + example: ['prop-1', 'prop-2', 'prop-3'], + type: [String], + }) + @IsArray() + @ArrayMinSize(2) + @ArrayMaxSize(5) + @IsString({ each: true }) + @Transform(({ value }) => (Array.isArray(value) ? value : [value])) + propertyIds!: string[]; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/valuation-history.dto.ts b/apps/api/src/modules/analytics/presentation/dto/valuation-history.dto.ts new file mode 100644 index 0000000..bddb827 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/valuation-history.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ValuationHistoryDto { + @ApiPropertyOptional({ description: 'Maximum number of history records (default: 50, max: 100)', default: 50 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Transform(({ value }) => (value != null ? parseInt(value, 10) : 50)) + limit?: number; +} diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx index 24b562d..29b7210 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx @@ -1,6 +1,10 @@ 'use client'; +import dynamic from 'next/dynamic'; import { useState } from 'react'; +import { ComparablesTable } from '@/components/valuation/comparables-table'; +import { ExportPdfButton } from '@/components/valuation/export-pdf-button'; +import { MarketContextCard } from '@/components/valuation/market-context-card'; import { ValuationForm } from '@/components/valuation/valuation-form'; import { ValuationHistory } from '@/components/valuation/valuation-history'; import { ValuationResults } from '@/components/valuation/valuation-results'; @@ -11,12 +15,29 @@ import { } from '@/lib/hooks/use-valuation'; import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; +// Lazy-load chart component (uses Recharts, no SSR) +const ValuationHistoryChart = dynamic( + () => + import('@/components/valuation/valuation-history-chart').then( + (m) => m.ValuationHistoryChart, + ), + { + ssr: false, + loading: () => ( +
+ Đang tải... +
+ ), + }, +); + export default function ValuationPage() { const [historyPage, setHistoryPage] = useState(1); const [selectedId, setSelectedId] = useState(null); const predictMutation = useValuationPredict(); - const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage); + const { data: historyData, isLoading: historyLoading } = + useValuationHistory(historyPage); const { data: selectedResult } = useValuationDetail(selectedId ?? ''); const currentResult: ValuationResult | undefined = @@ -33,15 +54,24 @@ export default function ValuationPage() { return (
-
-

Định giá AI

-

- Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường -

+ {/* Page header */} +
+
+

Định giá AI

+

+ Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường +

+
+ {currentResult && ( + + )}
- {/* Form + Results */} + {/* Form + Results (left 2 cols) */}
)} - {currentResult && } + {currentResult && ( + <> + {/* Main results with confidence badge + driver charts */} + + + {/* Comparables table (TanStack Table) */} + {currentResult.comparables.length > 0 && ( + + )} + + {/* Market context card */} + {currentResult.marketContext && ( + + )} + + {/* Valuation history chart */} + {currentResult.valuationHistory && + currentResult.valuationHistory.length >= 2 && ( + + )} + + )}
- {/* History sidebar */} + {/* History sidebar (right col) */}
(); + +function getSimilarityBadge(similarity: number): { + label: string; + variant: 'success' | 'warning' | 'info'; +} { + const pct = Math.round(similarity * 100); + if (pct >= 85) return { label: `${pct}% tương tự`, variant: 'success' }; + if (pct >= 70) return { label: `${pct}% tương tự`, variant: 'info' }; + return { label: `${pct}% tương tự`, variant: 'warning' }; +} + +const columns = [ + columnHelper.accessor('title', { + header: 'Bất động sản', + cell: (info) => { + const row = info.row.original; + return ( +
+

{info.getValue()}

+

+ + {row.district} + {row.address ? ` — ${row.address}` : ''} +

+
+ ); + }, + }), + columnHelper.accessor('areaM2', { + header: 'Diện tích', + cell: (info) => {info.getValue()} m², + }), + columnHelper.accessor('priceVND', { + header: 'Giá', + cell: (info) => ( + + {formatPrice(info.getValue())} + + ), + }), + columnHelper.accessor('pricePerM2', { + header: 'Giá/m²', + cell: (info) => ( + + {formatPricePerM2(info.getValue())} + + ), + }), + columnHelper.accessor('similarity', { + header: 'Tương đồng', + cell: (info) => { + const badge = getSimilarityBadge(info.getValue()); + return {badge.label}; + }, + sortDescFirst: true, + }), +]; + +export function ComparablesTable({ comparables }: ComparablesTableProps) { + const [sorting, setSorting] = useState([ + { id: 'similarity', desc: true }, + ]); + + const table = useReactTable({ + data: comparables, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + if (comparables.length === 0) return null; + + return ( + + + Bất động sản tương tự + + {comparables.length} bất động sản có đặc điểm tương tự trong khu vực + + + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : ( + + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+
+ ); +} diff --git a/apps/web/components/valuation/export-pdf-button.tsx b/apps/web/components/valuation/export-pdf-button.tsx new file mode 100644 index 0000000..fd34c19 --- /dev/null +++ b/apps/web/components/valuation/export-pdf-button.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { Download, Loader2 } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { Button } from '@/components/ui/button'; + +interface ExportPdfButtonProps { + /** CSS selector for the DOM element to capture */ + targetSelector: string; + /** Filename without extension */ + filename?: string; +} + +export function ExportPdfButton({ + targetSelector, + filename = 'dinh-gia-bat-dong-san', +}: ExportPdfButtonProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + const element = document.querySelector(targetSelector); + if (!element) { + console.error('Export target not found:', targetSelector); + return; + } + + // Dynamic imports for client-only PDF libraries + const [html2canvasModule, jsPDFModule] = await Promise.all([ + import('html2canvas'), + import('jspdf'), + ]); + const html2canvas = html2canvasModule.default; + const { jsPDF } = jsPDFModule; + + const canvas = await html2canvas(element as HTMLElement, { + scale: 2, + useCORS: true, + logging: false, + backgroundColor: '#ffffff', + }); + + const imgData = canvas.toDataURL('image/png'); + const imgWidth = canvas.width; + const imgHeight = canvas.height; + + // A4 dimensions in mm + const pdfWidth = 210; + const pdfMargin = 10; + const contentWidth = pdfWidth - 2 * pdfMargin; + const contentHeight = (imgHeight / imgWidth) * contentWidth; + + const pdf = new jsPDF({ + orientation: contentHeight > 297 - 2 * pdfMargin ? 'portrait' : 'portrait', + unit: 'mm', + format: 'a4', + }); + + // Add header + pdf.setFontSize(16); + pdf.setTextColor(34, 139, 34); // Green + pdf.text('GoodGo — Báo cáo định giá', pdfMargin, pdfMargin + 5); + pdf.setFontSize(10); + pdf.setTextColor(128, 128, 128); + pdf.text( + `Ngày: ${new Date().toLocaleDateString('vi-VN')}`, + pdfMargin, + pdfMargin + 12, + ); + + const headerHeight = 20; + const availableHeight = 297 - 2 * pdfMargin - headerHeight; + + if (contentHeight <= availableHeight) { + // Fits on one page + pdf.addImage( + imgData, + 'PNG', + pdfMargin, + pdfMargin + headerHeight, + contentWidth, + contentHeight, + ); + } else { + // Multi-page: split the image + let yOffset = 0; + let isFirstPage = true; + + while (yOffset < contentHeight) { + if (!isFirstPage) { + pdf.addPage(); + } + + const pageContentHeight = isFirstPage + ? availableHeight + : 297 - 2 * pdfMargin; + const pageTopMargin = isFirstPage + ? pdfMargin + headerHeight + : pdfMargin; + + // Calculate source rectangle in image coordinates + const srcY = (yOffset / contentHeight) * imgHeight; + const srcHeight = (pageContentHeight / contentHeight) * imgHeight; + + // Create a temporary canvas for this page slice + const pageCanvas = document.createElement('canvas'); + pageCanvas.width = imgWidth; + pageCanvas.height = Math.min(srcHeight, imgHeight - srcY); + + const ctx = pageCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage( + canvas, + 0, + srcY, + imgWidth, + pageCanvas.height, + 0, + 0, + imgWidth, + pageCanvas.height, + ); + } + + const pageImgData = pageCanvas.toDataURL('image/png'); + const sliceHeight = + (pageCanvas.height / imgWidth) * contentWidth; + + pdf.addImage( + pageImgData, + 'PNG', + pdfMargin, + pageTopMargin, + contentWidth, + sliceHeight, + ); + + yOffset += pageContentHeight; + isFirstPage = false; + } + } + + // Footer on last page + pdf.setFontSize(8); + pdf.setTextColor(180, 180, 180); + pdf.text( + 'Được tạo bởi GoodGo AI Valuation — goodgo.vn', + pdfMargin, + 297 - pdfMargin, + ); + + pdf.save(`${filename}.pdf`); + } catch (error) { + console.error('PDF export failed:', error); + } finally { + setIsExporting(false); + } + }, [targetSelector, filename]); + + return ( + + ); +} diff --git a/apps/web/components/valuation/market-context-card.tsx b/apps/web/components/valuation/market-context-card.tsx new file mode 100644 index 0000000..da2fb1d --- /dev/null +++ b/apps/web/components/valuation/market-context-card.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { + Building, + CalendarDays, + TrendingDown, + TrendingUp, + Users, + Warehouse, +} from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { formatPrice, formatPricePerM2 } from '@/lib/currency'; +import type { MarketContext } from '@/lib/valuation-api'; + +interface MarketContextCardProps { + context: MarketContext; +} + +export function MarketContextCard({ context }: MarketContextCardProps) { + const isGrowthPositive = context.priceGrowthYoY >= 0; + + const stats = [ + { + label: 'Giá trung bình/m²', + value: formatPricePerM2(context.avgPricePerM2), + icon: Building, + }, + { + label: 'Giá trung vị', + value: formatPrice(context.medianPrice), + icon: Warehouse, + }, + { + label: 'Tăng trưởng YoY', + value: `${isGrowthPositive ? '+' : ''}${context.priceGrowthYoY.toFixed(1)}%`, + icon: isGrowthPositive ? TrendingUp : TrendingDown, + color: isGrowthPositive ? 'text-green-600' : 'text-red-600', + }, + { + label: 'Chỉ số nhu cầu', + value: `${context.demandIndex.toFixed(0)}/100`, + icon: Users, + }, + { + label: 'Nguồn cung', + value: `${context.supplyCount.toLocaleString('vi-VN')} BĐS`, + icon: Building, + }, + { + label: 'Thời gian bán TB', + value: `${context.avgDaysOnMarket} ngày`, + icon: CalendarDays, + }, + ]; + + return ( + + + Bối cảnh thị trường + + {context.district}, {context.city} — {context.period} + + + +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ +
+
+

{stat.label}

+

+ {stat.value} +

+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/valuation/valuation-form.tsx b/apps/web/components/valuation/valuation-form.tsx index c3a8674..8a82344 100644 --- a/apps/web/components/valuation/valuation-form.tsx +++ b/apps/web/components/valuation/valuation-form.tsx @@ -1,12 +1,21 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Bot, ImagePlus, Search, X } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; +import { useProjectSearch } from '@/lib/hooks/use-valuation'; import { valuationFormSchema, type ValuationFormData, @@ -30,15 +39,76 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) { const { register, handleSubmit, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(valuationFormSchema), defaultValues: { city: 'Ho Chi Minh', hasLegalPaper: true, + deepAnalysis: false, }, }); + // Project autocomplete state + const [projectQuery, setProjectQuery] = useState(''); + const [projectName, setProjectName] = useState(''); + const [showProjectDropdown, setShowProjectDropdown] = useState(false); + const { data: projectResults } = useProjectSearch(projectQuery); + const projectInputRef = useRef(null); + + // Image upload state + const [imagePreview, setImagePreview] = useState(null); + const [imageUrl, setImageUrl] = useState(null); + const fileInputRef = useRef(null); + + const handleProjectSearch = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setProjectQuery(value); + setProjectName(value); + setShowProjectDropdown(value.length >= 2); + if (!value) { + setValue('projectId', ''); + } + }, [setValue]); + + const handleSelectProject = useCallback( + (id: string, name: string) => { + setValue('projectId', id); + setProjectName(name); + setProjectQuery(''); + setShowProjectDropdown(false); + }, + [setValue], + ); + + const handleImageChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Show local preview + const reader = new FileReader(); + reader.onload = (ev) => { + setImagePreview(ev.target?.result as string); + }; + reader.readAsDataURL(file); + + // In production, upload to server and get URL + // For now we store as data URL for preview purposes + setImageUrl(URL.createObjectURL(file)); + }, + [], + ); + + const handleClearImage = useCallback(() => { + setImagePreview(null); + setImageUrl(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, []); + const handleFormSubmit = (data: ValuationFormData) => { onSubmit({ propertyType: data.propertyType, @@ -52,6 +122,10 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) { roadWidth: toNum(data.roadWidth), yearBuilt: toNum(data.yearBuilt), hasLegalPaper: data.hasLegalPaper, + projectId: data.projectId || undefined, + description: data.description || undefined, + deepAnalysis: data.deepAnalysis, + imageUrl: imageUrl || undefined, }); }; @@ -65,6 +139,56 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
+ {/* Project selector (autocomplete) */} +
+ +
+ + projectQuery.length >= 2 && setShowProjectDropdown(true)} + onBlur={() => setTimeout(() => setShowProjectDropdown(false), 200)} + placeholder="Tìm kiếm dự án..." + className="pl-9" + /> + {projectName && ( + + )} + {showProjectDropdown && projectResults?.data && projectResults.data.length > 0 && ( +
+ {projectResults.data.map((project) => ( + + ))} +
+ )} +
+
+ {/* Row 1: Property type + City */}
@@ -194,15 +318,84 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
- {/* Legal paper checkbox */} -
- + +
+ {imagePreview ? ( +
+ Ảnh bất động sản + +
+ ) : ( + + )} + +

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

+
+
+ + {/* Description textarea */} +
+ +