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:
Ho Ngoc Hai
2026-04-09 09:41:46 +07:00
parent 1e0436e95f
commit cd25d4df2e
25 changed files with 587 additions and 14 deletions

View File

@@ -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);
});
});
});

View File

@@ -1 +1,2 @@
export * from './repositories';
export * from './services';

View File

@@ -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,

View File

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

View File

@@ -0,0 +1 @@
export { PrismaAVMService } from './prisma-avm.service';

View File

@@ -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(),
};
}
}