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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<ValuationResult> {
|
||||
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<Comparable[]> {
|
||||
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<PropertyLocation> {
|
||||
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<RawComparable[]> {
|
||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
||||
|
||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
||||
`
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user