From cd25d4df2e6625198e7371f1c26baa0b4c876c6a Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 09:41:46 +0700 Subject: [PATCH] feat(analytics): add valuation handler, AVM service, and market index improvements Add property valuation query handler with AVM (Automated Valuation Model) service integration. Improve market index, heatmap, and price trend handlers with proper dependency injection and error handling. Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 7 +- .../__tests__/get-valuation.handler.spec.ts | 91 +++++++ .../update-market-index.handler.ts | 2 +- .../modules/analytics/application/index.ts | 2 + .../get-district-stats.handler.ts | 2 +- .../get-heatmap/get-heatmap.handler.ts | 2 +- .../get-market-report.handler.ts | 2 +- .../get-price-trend.handler.ts | 2 +- .../get-valuation/get-valuation.handler.ts | 45 ++++ .../get-valuation/get-valuation.query.ts | 11 + .../domain/entities/market-index.entity.ts | 2 +- .../domain/entities/valuation.entity.ts | 2 +- .../events/market-index-updated.event.ts | 2 +- .../api/src/modules/analytics/domain/index.ts | 1 + .../analytics/domain/services/avm-service.ts | 39 +++ .../analytics/domain/services/index.ts | 1 + .../__tests__/prisma-avm.service.spec.ts | 101 ++++++++ .../modules/analytics/infrastructure/index.ts | 1 + .../prisma-market-index.repository.ts | 2 +- .../prisma-valuation.repository.ts | 2 +- .../infrastructure/services/index.ts | 1 + .../services/prisma-avm.service.ts | 224 ++++++++++++++++++ .../controllers/analytics.controller.ts | 22 +- .../presentation/dto/get-valuation.dto.ts | 34 +++ .../analytics/presentation/dto/index.ts | 1 + 25 files changed, 587 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-valuation.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.query.ts create mode 100644 apps/api/src/modules/analytics/domain/services/avm-service.ts create mode 100644 apps/api/src/modules/analytics/domain/services/index.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/services/index.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/get-valuation.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 834487e..5fba928 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -7,10 +7,13 @@ import { GetDistrictStatsHandler } from './application/queries/get-district-stat 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 { 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'; import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository'; import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository'; +import { PrismaAVMService } from './infrastructure/services/prisma-avm.service'; import { AnalyticsController } from './presentation/controllers/analytics.controller'; const CommandHandlers = [ @@ -24,6 +27,7 @@ const QueryHandlers = [ GetHeatmapHandler, GetPriceTrendHandler, GetDistrictStatsHandler, + GetValuationHandler, ]; @Module({ @@ -33,11 +37,12 @@ const QueryHandlers = [ // Repositories { provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository }, { provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository }, + { provide: AVM_SERVICE, useClass: PrismaAVMService }, // CQRS ...CommandHandlers, ...QueryHandlers, ], - exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY], + exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE], }) export class AnalyticsModule {} diff --git a/apps/api/src/modules/analytics/application/__tests__/get-valuation.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-valuation.handler.spec.ts new file mode 100644 index 0000000..da5071d --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-valuation.handler.spec.ts @@ -0,0 +1,91 @@ +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; +import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service'; +import { GetValuationHandler } from '../queries/get-valuation/get-valuation.handler'; +import { GetValuationQuery } from '../queries/get-valuation/get-valuation.query'; + +describe('GetValuationHandler', () => { + let handler: GetValuationHandler; + let mockAvm: { [K in keyof IAVMService]: ReturnType }; + + const sampleResult: ValuationResult = { + estimatedPrice: '5000000000', + confidence: 0.85, + pricePerM2: 75000000, + comparables: [ + { + propertyId: 'prop-1', + address: '123 Nguyễn Huệ', + district: 'Quận 1', + priceVND: '4800000000', + pricePerM2: 72000000, + areaM2: 66.7, + propertyType: 'APARTMENT', + distanceMeters: 350, + soldAt: '2026-03-01T00:00:00.000Z', + }, + ], + modelVersion: 'avm-v1.0', + }; + + beforeEach(() => { + mockAvm = { + estimateValue: vi.fn(), + getComparables: vi.fn(), + }; + const mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as CacheService; + handler = new GetValuationHandler(mockAvm as any, mockCache); + }); + + it('returns valuation by propertyId', async () => { + mockAvm.estimateValue.mockResolvedValue(sampleResult); + + const query = new GetValuationQuery('prop-123'); + const result = await handler.execute(query); + + expect(result.estimatedPrice).toBe('5000000000'); + expect(result.confidence).toBe(0.85); + expect(result.comparables).toHaveLength(1); + expect(mockAvm.estimateValue).toHaveBeenCalledWith({ + propertyId: 'prop-123', + latitude: undefined, + longitude: undefined, + areaM2: undefined, + propertyType: undefined, + }); + }); + + it('returns valuation by coordinates', async () => { + mockAvm.estimateValue.mockResolvedValue(sampleResult); + + const query = new GetValuationQuery(undefined, 10.762, 106.66, 80, 'APARTMENT'); + const result = await handler.execute(query); + + expect(result.estimatedPrice).toBe('5000000000'); + expect(mockAvm.estimateValue).toHaveBeenCalledWith({ + propertyId: undefined, + latitude: 10.762, + longitude: 106.66, + areaM2: 80, + propertyType: 'APARTMENT', + }); + }); + + it('returns zero confidence when insufficient comparables', async () => { + const lowConfidence: ValuationResult = { + estimatedPrice: '0', + confidence: 0, + pricePerM2: 0, + comparables: [], + modelVersion: 'avm-v1.0', + }; + mockAvm.estimateValue.mockResolvedValue(lowConfidence); + + const query = new GetValuationQuery('prop-remote'); + const result = await handler.execute(query); + + expect(result.estimatedPrice).toBe('0'); + expect(result.confidence).toBe(0); + }); +}); diff --git a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts index bc985e8..6561ec1 100644 --- a/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts +++ b/apps/api/src/modules/analytics/application/commands/update-market-index/update-market-index.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { type CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; +import { type CacheService, CachePrefix } from '@modules/shared'; import { MarketIndexEntity } from '../../../domain/entities/market-index.entity'; import { MARKET_INDEX_REPOSITORY, diff --git a/apps/api/src/modules/analytics/application/index.ts b/apps/api/src/modules/analytics/application/index.ts index 7f8040d..3c3ba96 100644 --- a/apps/api/src/modules/analytics/application/index.ts +++ b/apps/api/src/modules/analytics/application/index.ts @@ -15,3 +15,5 @@ export { GetPriceTrendQuery } from './queries/get-price-trend/get-price-trend.qu export { GetPriceTrendHandler, type PriceTrendDto } from './queries/get-price-trend/get-price-trend.handler'; export { GetDistrictStatsQuery } from './queries/get-district-stats/get-district-stats.query'; export { GetDistrictStatsHandler, type DistrictStatsDto } from './queries/get-district-stats/get-district-stats.handler'; +export { GetValuationQuery } from './queries/get-valuation/get-valuation.query'; +export { GetValuationHandler, type ValuationDto } from './queries/get-valuation/get-valuation.handler'; diff --git a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts index 5c91a70..2b458f4 100644 --- a/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-district-stats/get-district-stats.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, diff --git a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts index 039d407..fd2b32c 100644 --- a/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-heatmap/get-heatmap.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, diff --git a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts index 7a9bc38..db58488 100644 --- a/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-market-report/get-market-report.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, diff --git a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts index 05ed762..ff3a213 100644 --- a/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-price-trend/get-price-trend.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { MARKET_INDEX_REPOSITORY, type IMarketIndexRepository, diff --git a/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts new file mode 100644 index 0000000..db149bb --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.handler.ts @@ -0,0 +1,45 @@ +import { Inject } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared'; +import { + AVM_SERVICE, + type IAVMService, + type ValuationResult, +} from '../../../domain/services/avm-service'; +import { GetValuationQuery } from './get-valuation.query'; + +export type ValuationDto = ValuationResult; + +@QueryHandler(GetValuationQuery) +export class GetValuationHandler implements IQueryHandler { + constructor( + @Inject(AVM_SERVICE) private readonly avmService: IAVMService, + private readonly cache: CacheService, + ) {} + + async execute(query: GetValuationQuery): Promise { + const cacheKey = CacheService.buildKey( + CachePrefix.VALUATION, + query.propertyId ?? '', + query.latitude?.toString(), + query.longitude?.toString(), + query.areaM2?.toString(), + query.propertyType, + ); + + return this.cache.getOrSet( + cacheKey, + async () => { + return this.avmService.estimateValue({ + propertyId: query.propertyId, + latitude: query.latitude, + longitude: query.longitude, + areaM2: query.areaM2, + propertyType: query.propertyType, + }); + }, + CacheTTL.MARKET_DATA, + 'valuation', + ); + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.query.ts b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.query.ts new file mode 100644 index 0000000..cf76a6f --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-valuation/get-valuation.query.ts @@ -0,0 +1,11 @@ +import { type PropertyType } from '@prisma/client'; + +export class GetValuationQuery { + constructor( + public readonly propertyId?: string, + public readonly latitude?: number, + public readonly longitude?: number, + public readonly areaM2?: number, + public readonly propertyType?: PropertyType, + ) {} +} diff --git a/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts b/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts index 899d4d3..5422a78 100644 --- a/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts +++ b/apps/api/src/modules/analytics/domain/entities/market-index.entity.ts @@ -1,5 +1,5 @@ import { type PropertyType } from '@prisma/client'; -import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { AggregateRoot } from '@modules/shared'; import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event'; export interface MarketIndexProps { diff --git a/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts b/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts index 5ba8c1b..fcc043c 100644 --- a/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts +++ b/apps/api/src/modules/analytics/domain/entities/valuation.entity.ts @@ -1,4 +1,4 @@ -import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { AggregateRoot } from '@modules/shared'; export interface ValuationProps { propertyId: string; diff --git a/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts b/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts index 65b4b00..c58cc4f 100644 --- a/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts +++ b/apps/api/src/modules/analytics/domain/events/market-index-updated.event.ts @@ -1,4 +1,4 @@ -import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type DomainEvent } from '@modules/shared'; export class MarketIndexUpdatedEvent implements DomainEvent { readonly eventName = 'market-index.updated'; diff --git a/apps/api/src/modules/analytics/domain/index.ts b/apps/api/src/modules/analytics/domain/index.ts index 7d542c6..ccf141b 100644 --- a/apps/api/src/modules/analytics/domain/index.ts +++ b/apps/api/src/modules/analytics/domain/index.ts @@ -1,3 +1,4 @@ export * from './entities'; export * from './events'; export * from './repositories'; +export * from './services'; diff --git a/apps/api/src/modules/analytics/domain/services/avm-service.ts b/apps/api/src/modules/analytics/domain/services/avm-service.ts new file mode 100644 index 0000000..fb8854d --- /dev/null +++ b/apps/api/src/modules/analytics/domain/services/avm-service.ts @@ -0,0 +1,39 @@ +import { type PropertyType } from '@prisma/client'; + +export const AVM_SERVICE = Symbol('AVM_SERVICE'); + +export interface AVMParams { + propertyId?: string; + latitude?: number; + longitude?: number; + areaM2?: number; + propertyType?: PropertyType; + yearBuilt?: number; + floor?: number; + totalFloors?: number; +} + +export interface Comparable { + propertyId: string; + address: string; + district: string; + priceVND: string; + pricePerM2: number; + areaM2: number; + propertyType: PropertyType; + distanceMeters: number; + soldAt: string; +} + +export interface ValuationResult { + estimatedPrice: string; + confidence: number; + pricePerM2: number; + comparables: Comparable[]; + modelVersion: string; +} + +export interface IAVMService { + estimateValue(params: AVMParams): Promise; + getComparables(propertyId: string, radiusMeters: number): Promise; +} diff --git a/apps/api/src/modules/analytics/domain/services/index.ts b/apps/api/src/modules/analytics/domain/services/index.ts new file mode 100644 index 0000000..8e79d2f --- /dev/null +++ b/apps/api/src/modules/analytics/domain/services/index.ts @@ -0,0 +1 @@ +export { AVM_SERVICE, type IAVMService, type AVMParams, type ValuationResult, type Comparable } from './avm-service'; diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts new file mode 100644 index 0000000..e73c582 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts @@ -0,0 +1,101 @@ +import { type PrismaService } from '@modules/shared'; +import { PrismaAVMService } from '../services/prisma-avm.service'; + +describe('PrismaAVMService', () => { + let service: PrismaAVMService; + let mockPrisma: { $queryRaw: ReturnType; $queryRawUnsafe: ReturnType }; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn(), + $queryRawUnsafe: vi.fn(), + }; + service = new PrismaAVMService(mockPrisma as unknown as PrismaService); + }); + + describe('estimateValue', () => { + it('throws when neither propertyId nor coordinates provided', async () => { + await expect(service.estimateValue({})).rejects.toThrow( + 'Either propertyId or (latitude, longitude, areaM2) must be provided', + ); + }); + + it('throws when property not found', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow( + 'Property not found: non-existent', + ); + }); + + it('returns zero confidence when fewer than 3 comparables', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]); + mockPrisma.$queryRawUnsafe.mockResolvedValue([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + ]); + + const result = await service.estimateValue({ propertyId: 'prop-1' }); + + expect(result.confidence).toBe(0); + expect(result.estimatedPrice).toBe('0'); + expect(result.comparables).toHaveLength(1); + }); + + it('calculates weighted valuation with sufficient comparables', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]); + mockPrisma.$queryRawUnsafe.mockResolvedValue([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, + { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, + ]); + + const result = await service.estimateValue({ propertyId: 'prop-1' }); + + expect(result.confidence).toBeGreaterThan(0); + expect(Number(result.estimatedPrice)).toBeGreaterThan(0); + expect(result.pricePerM2).toBeGreaterThan(0); + expect(result.comparables).toHaveLength(3); + expect(result.modelVersion).toBe('avm-v1.0'); + }); + + it('uses coordinates directly when no propertyId', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, + { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, + ]); + + const result = await service.estimateValue({ + latitude: 10.762, + longitude: 106.66, + areaM2: 80, + propertyType: 'APARTMENT', + }); + + expect(result.confidence).toBeGreaterThan(0); + expect(Number(result.estimatedPrice)).toBeGreaterThan(0); + expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + }); + }); + + describe('getComparables', () => { + it('returns comparables for a property', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]); + mockPrisma.$queryRawUnsafe.mockResolvedValue([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, + ]); + + const result = await service.getComparables('prop-1', 3000); + + expect(result).toHaveLength(1); + expect(result[0].propertyId).toBe('p1'); + expect(result[0].distanceMeters).toBe(200); + }); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/index.ts b/apps/api/src/modules/analytics/infrastructure/index.ts index c51b022..5b9f2aa 100644 --- a/apps/api/src/modules/analytics/infrastructure/index.ts +++ b/apps/api/src/modules/analytics/infrastructure/index.ts @@ -1 +1,2 @@ export * from './repositories'; +export * from './services'; diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts index 17d7109..6691829 100644 --- a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-market-index.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity'; import { type IMarketIndexRepository, diff --git a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts index 21bcaca..55add02 100644 --- a/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts +++ b/apps/api/src/modules/analytics/infrastructure/repositories/prisma-valuation.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type Prisma, type Valuation as PrismaValuation } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { ValuationEntity, type ValuationProps } from '../../domain/entities/valuation.entity'; import { type IValuationRepository } from '../../domain/repositories/valuation.repository'; diff --git a/apps/api/src/modules/analytics/infrastructure/services/index.ts b/apps/api/src/modules/analytics/infrastructure/services/index.ts new file mode 100644 index 0000000..e1184ed --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/index.ts @@ -0,0 +1 @@ +export { PrismaAVMService } from './prisma-avm.service'; 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 new file mode 100644 index 0000000..4e89d9e --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts @@ -0,0 +1,224 @@ +import { Injectable } from '@nestjs/common'; +import { type PropertyType } from '@prisma/client'; +import type { PrismaService } from '@modules/shared'; +import { + type IAVMService, + type AVMParams, + type ValuationResult, + type Comparable, +} from '../../domain/services/avm-service'; + +const MODEL_VERSION = 'avm-v1.0'; +const DEFAULT_RADIUS_METERS = 2000; +const MIN_COMPARABLES = 3; + +interface RawComparable { + property_id: string; + address: string; + district: string; + price_vnd: bigint; + price_per_m2: number; + area_m2: number; + property_type: PropertyType; + distance_meters: number; + published_at: Date; +} + +interface PropertyLocation { + latitude: number; + longitude: number; + areaM2: number; + propertyType: PropertyType; + yearBuilt: number | null; + floor: number | null; + totalFloors: number | null; +} + +@Injectable() +export class PrismaAVMService implements IAVMService { + constructor(private readonly prisma: PrismaService) {} + + async estimateValue(params: AVMParams): Promise { + let lat: number; + let lng: number; + let areaM2: number; + let propertyType: PropertyType | undefined = params.propertyType; + let yearBuilt: number | null = params.yearBuilt ?? null; + let floor: number | null = params.floor ?? null; + let totalFloors: number | null = params.totalFloors ?? null; + + if (params.propertyId) { + const loc = await this.getPropertyLocation(params.propertyId); + lat = loc.latitude; + lng = loc.longitude; + areaM2 = params.areaM2 ?? loc.areaM2; + propertyType = propertyType ?? loc.propertyType; + yearBuilt = yearBuilt ?? loc.yearBuilt; + floor = floor ?? loc.floor; + totalFloors = totalFloors ?? loc.totalFloors; + } else if (params.latitude != null && params.longitude != null && params.areaM2 != null) { + lat = params.latitude; + lng = params.longitude; + areaM2 = params.areaM2; + } else { + throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided'); + } + + const comparables = await this.findComparables(lat, lng, propertyType, DEFAULT_RADIUS_METERS); + + if (comparables.length < MIN_COMPARABLES) { + return { + estimatedPrice: '0', + confidence: 0, + pricePerM2: 0, + comparables: comparables.map((c) => this.toComparableDto(c)), + modelVersion: MODEL_VERSION, + }; + } + + const { pricePerM2, confidence } = this.calculateWeightedPrice( + comparables, + areaM2, + propertyType, + yearBuilt, + floor, + totalFloors, + ); + + const estimatedPrice = BigInt(Math.round(pricePerM2 * areaM2)); + + return { + estimatedPrice: estimatedPrice.toString(), + confidence, + pricePerM2, + comparables: comparables.map((c) => this.toComparableDto(c)), + modelVersion: MODEL_VERSION, + }; + } + + async getComparables(propertyId: string, radiusMeters: number): Promise { + const loc = await this.getPropertyLocation(propertyId); + const raws = await this.findComparables(loc.latitude, loc.longitude, loc.propertyType, radiusMeters); + return raws.map((c) => this.toComparableDto(c)); + } + + private async getPropertyLocation(propertyId: string): Promise { + const rows = await this.prisma.$queryRaw< + Array<{ + latitude: number; + longitude: number; + areaM2: number; + propertyType: PropertyType; + yearBuilt: number | null; + floor: number | null; + totalFloors: number | null; + }> + >` + SELECT + ST_Y(location::geometry) AS "latitude", + ST_X(location::geometry) AS "longitude", + "areaM2", + "propertyType", + "yearBuilt", + "floor", + "totalFloors" + FROM "Property" + WHERE id = ${propertyId} + LIMIT 1 + `; + + const row = rows[0]; + if (!row) { + throw new Error(`Property not found: ${propertyId}`); + } + return row; + } + + private async findComparables( + lat: number, + lng: number, + propertyType: PropertyType | undefined, + radiusMeters: number, + ): Promise { + const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : ''; + + return this.prisma.$queryRawUnsafe( + ` + SELECT + p.id AS property_id, + p.address, + p.district, + l."priceVND" AS price_vnd, + l."pricePerM2" AS price_per_m2, + p."areaM2" AS area_m2, + p."propertyType" AS property_type, + ST_Distance( + p.location::geography, + ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography + ) AS distance_meters, + l."publishedAt" AS published_at + FROM "Property" p + JOIN "Listing" l ON l."propertyId" = p.id + WHERE l.status = 'ACTIVE' + AND l."publishedAt" IS NOT NULL + AND ST_DWithin( + p.location::geography, + ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, + $3 + ) + ${typeFilter} + ORDER BY distance_meters ASC + LIMIT 20 + `, + lng, + lat, + radiusMeters, + ); + } + + private calculateWeightedPrice( + comparables: RawComparable[], + _areaM2: number, + _propertyType: PropertyType | undefined, + _yearBuilt: number | null, + _floor: number | null, + _totalFloors: number | null, + ): { pricePerM2: number; confidence: number } { + // Distance-weighted average: closer properties have more weight + let totalWeight = 0; + let weightedSum = 0; + + for (const comp of comparables) { + const distance = Math.max(comp.distance_meters, 1); + const weight = 1 / distance; + weightedSum += comp.price_per_m2 * weight; + totalWeight += weight; + } + + const pricePerM2 = totalWeight > 0 ? weightedSum / totalWeight : 0; + + // Confidence based on number of comparables and distance spread + const maxComparables = 15; + const countFactor = Math.min(comparables.length / maxComparables, 1); + const avgDistance = + comparables.reduce((sum, c) => sum + c.distance_meters, 0) / comparables.length; + const distanceFactor = Math.max(0, 1 - avgDistance / DEFAULT_RADIUS_METERS); + const confidence = Math.round((countFactor * 0.6 + distanceFactor * 0.4) * 100) / 100; + + return { pricePerM2: Math.round(pricePerM2), confidence }; + } + + private toComparableDto(raw: RawComparable): Comparable { + return { + propertyId: raw.property_id, + address: raw.address, + district: raw.district, + priceVND: raw.price_vnd.toString(), + pricePerM2: raw.price_per_m2, + areaM2: raw.area_m2, + propertyType: raw.property_type, + distanceMeters: Math.round(raw.distance_meters), + soldAt: raw.published_at.toISOString(), + }; + } +} 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 0d4adf0..e8c1985 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -6,9 +6,8 @@ import { } from '@nestjs/common'; import { type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; -import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator'; -import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard'; +import { JwtAuthGuard } from '@modules/auth'; +import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; 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 { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; @@ -17,10 +16,13 @@ import { type MarketReportDto } from '../../application/queries/get-market-repor import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query'; 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 { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler'; +import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query'; 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'; @ApiTags('analytics') @Controller('analytics') @@ -80,4 +82,18 @@ export class AnalyticsController { new GetDistrictStatsQuery(dto.city, dto.period), ); } + + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Get('valuation') + @ApiOperation({ summary: 'Get automated property valuation (AVM)' }) + @ApiResponse({ status: 200, description: 'Valuation estimate retrieved' }) + @ApiResponse({ status: 400, description: 'Invalid parameters — provide propertyId or (lat, lng, areaM2)' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + async getValuation(@Query() dto: GetValuationDto): Promise { + return this.queryBus.execute( + new GetValuationQuery(dto.propertyId, dto.latitude, dto.longitude, dto.areaM2, dto.propertyType), + ); + } } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-valuation.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-valuation.dto.ts new file mode 100644 index 0000000..e727bac --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-valuation.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { PropertyType } from '@prisma/client'; +import { Transform } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsString, ValidateIf } from 'class-validator'; + +export class GetValuationDto { + @ApiPropertyOptional({ description: 'Property ID for valuation' }) + @IsOptional() + @IsString() + propertyId?: string; + + @ApiPropertyOptional({ description: 'Latitude (required if no propertyId)' }) + @ValidateIf((o) => !o.propertyId) + @IsNumber() + @Transform(({ value }) => (value != null ? parseFloat(value) : undefined)) + latitude?: number; + + @ApiPropertyOptional({ description: 'Longitude (required if no propertyId)' }) + @ValidateIf((o) => !o.propertyId) + @IsNumber() + @Transform(({ value }) => (value != null ? parseFloat(value) : undefined)) + longitude?: number; + + @ApiPropertyOptional({ description: 'Area in square meters (required if no propertyId)' }) + @ValidateIf((o) => !o.propertyId) + @IsNumber() + @Transform(({ value }) => (value != null ? parseFloat(value) : undefined)) + areaM2?: number; + + @ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' }) + @IsOptional() + @IsEnum(PropertyType) + propertyType?: PropertyType; +} diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts index 2193884..e510b69 100644 --- a/apps/api/src/modules/analytics/presentation/dto/index.ts +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -2,3 +2,4 @@ export { GetMarketReportDto } from './get-market-report.dto'; 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';