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';