import { Injectable } from '@nestjs/common'; import { type PropertyType } from '@prisma/client'; import { PrismaService } from '@modules/shared'; import { type IAVMService, type AVMParams, type ValuationResult, type Comparable, type BatchValuationItem, type BatchValuationResult, } from '../../domain/services/avm-service'; import { type RawComparable, toComparableDto, calculateWeightedPrice, } from './avm-calculation.helper'; const MODEL_VERSION = 'avm-v1.0'; const DEFAULT_RADIUS_METERS = 2000; const MIN_COMPARABLES = 3; 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 { const resolved = await this.resolveParams(params); const comparables = await this.findComparables( resolved.lat, resolved.lng, resolved.propertyType, DEFAULT_RADIUS_METERS, ); if (comparables.length < MIN_COMPARABLES) { return { estimatedPrice: '0', confidence: 0, pricePerM2: 0, comparables: comparables.map(toComparableDto), modelVersion: MODEL_VERSION, }; } const { pricePerM2, confidence } = calculateWeightedPrice( comparables, resolved.areaM2, resolved.propertyType, resolved.yearBuilt, resolved.floor, resolved.totalFloors, ); const estimatedPrice = BigInt(Math.round(pricePerM2 * resolved.areaM2)); return { estimatedPrice: estimatedPrice.toString(), confidence, pricePerM2, comparables: comparables.map(toComparableDto), 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(toComparableDto); } async estimateBatch(items: BatchValuationItem[]): Promise { return Promise.all( items.map(async (item) => { try { const valuation = await this.estimateValue({ propertyId: item.propertyId }); return { propertyId: item.propertyId, valuation }; } catch { return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' }; } }), ); } private async resolveParams(params: AVMParams): Promise<{ lat: number; lng: number; areaM2: number; propertyType: PropertyType | undefined; yearBuilt: number | null; floor: number | null; totalFloors: number | null; }> { if (params.propertyId) { const loc = await this.getPropertyLocation(params.propertyId); return { lat: loc.latitude, lng: loc.longitude, areaM2: params.areaM2 ?? loc.areaM2, propertyType: params.propertyType ?? loc.propertyType, yearBuilt: params.yearBuilt ?? loc.yearBuilt, floor: params.floor ?? loc.floor, totalFloors: params.totalFloors ?? loc.totalFloors, }; } if (params.latitude != null && params.longitude != null && params.areaM2 != null) { return { lat: params.latitude, lng: params.longitude, areaM2: params.areaM2, propertyType: params.propertyType, yearBuilt: params.yearBuilt ?? null, floor: params.floor ?? null, totalFloors: params.totalFloors ?? null, }; } throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided'); } private async getPropertyLocation(propertyId: string): Promise { const rows = await this.prisma.$queryRaw` 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, ); } }