feat(analytics): AVM v2 batch valuation, comparison, history + frontend upgrade
Add batch valuation (POST /analytics/valuation/batch, max 50 properties), valuation comparison (POST /analytics/valuation/compare, 2-5 properties), and history endpoint (GET /analytics/valuation/history/:propertyId) with confidence explanation helper. Frontend: enhanced valuation form with project autocomplete and deep analysis toggle, results with confidence badges and price range visualization, comparables table, history chart, market context card, and PDF export. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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<BatchValuationQuery> {
|
||||
constructor(
|
||||
@Inject(AVM_SERVICE) private readonly avmService: IAVMService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: BatchValuationQuery): Promise<BatchValuationDto> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class BatchValuationQuery {
|
||||
constructor(
|
||||
public readonly propertyIds: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -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<ValuationComparisonQuery> {
|
||||
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<ValuationComparisonDto> {
|
||||
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<ValuationComparisonDto> {
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class ValuationComparisonQuery {
|
||||
constructor(
|
||||
public readonly propertyIds: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -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<ValuationHistoryQuery> {
|
||||
constructor(
|
||||
@Inject(VALUATION_REPOSITORY) private readonly valuationRepo: IValuationRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: ValuationHistoryQuery): Promise<ValuationHistoryDto> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class ValuationHistoryQuery {
|
||||
constructor(
|
||||
public readonly propertyId: string,
|
||||
public readonly limit: number = 50,
|
||||
) {}
|
||||
}
|
||||
@@ -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<ValuationResult>;
|
||||
getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]>;
|
||||
estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]>;
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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<BatchValuationResult[]> {
|
||||
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<ValuationResult> {
|
||||
const propertyData = params.propertyId
|
||||
? await this.getPropertyDetails(params.propertyId)
|
||||
|
||||
@@ -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<BatchValuationResult[]> {
|
||||
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;
|
||||
|
||||
@@ -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<BatchValuationQueryDto> {
|
||||
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<ValuationHistoryResultDto> {
|
||||
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<ValuationComparisonResultDto> {
|
||||
return this.queryBus.execute(
|
||||
new ValuationComparisonQuery(dto.propertyIds),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user