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,101 @@
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { PrismaAVMService } from '../services/prisma-avm.service';
|
||||
|
||||
describe('PrismaAVMService', () => {
|
||||
let service: PrismaAVMService;
|
||||
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn>; $queryRawUnsafe: ReturnType<typeof vi.fn> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PrismaAVMService } from './prisma-avm.service';
|
||||
@@ -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