refactor(api): split 3 oversized files to comply with 200 LOC convention
Extract shared logic from postgres-search.repository.ts (361→105), prisma-agent.repository.ts (298→179), and prisma-avm.service.ts (224→143) into focused helper modules. All existing tests (92/92) pass unchanged. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { type PropertyType } from '@prisma/client';
|
||||
import { type Comparable } from '../../domain/services/avm-service';
|
||||
|
||||
const DEFAULT_RADIUS_METERS = 2000;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** Map a raw SQL comparable row to the domain Comparable DTO. */
|
||||
export function 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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance-weighted average price calculation.
|
||||
* Closer properties carry more weight in the estimate.
|
||||
*/
|
||||
export function calculateWeightedPrice(
|
||||
comparables: RawComparable[],
|
||||
_areaM2: number,
|
||||
_propertyType: PropertyType | undefined,
|
||||
_yearBuilt: number | null,
|
||||
_floor: number | null,
|
||||
_totalFloors: number | null,
|
||||
): { pricePerM2: number; confidence: number } {
|
||||
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 };
|
||||
}
|
||||
@@ -7,23 +7,16 @@ import {
|
||||
type ValuationResult,
|
||||
type Comparable,
|
||||
} 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 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;
|
||||
@@ -39,59 +32,32 @@ 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);
|
||||
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((c) => this.toComparableDto(c)),
|
||||
comparables: comparables.map(toComparableDto),
|
||||
modelVersion: MODEL_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
const { pricePerM2, confidence } = this.calculateWeightedPrice(
|
||||
comparables,
|
||||
areaM2,
|
||||
propertyType,
|
||||
yearBuilt,
|
||||
floor,
|
||||
totalFloors,
|
||||
const { pricePerM2, confidence } = calculateWeightedPrice(
|
||||
comparables, resolved.areaM2, resolved.propertyType,
|
||||
resolved.yearBuilt, resolved.floor, resolved.totalFloors,
|
||||
);
|
||||
|
||||
const estimatedPrice = BigInt(Math.round(pricePerM2 * areaM2));
|
||||
const estimatedPrice = BigInt(Math.round(pricePerM2 * resolved.areaM2));
|
||||
|
||||
return {
|
||||
estimatedPrice: estimatedPrice.toString(),
|
||||
confidence,
|
||||
pricePerM2,
|
||||
comparables: comparables.map((c) => this.toComparableDto(c)),
|
||||
comparables: comparables.map(toComparableDto),
|
||||
modelVersion: MODEL_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -99,126 +65,79 @@ export class PrismaAVMService implements IAVMService {
|
||||
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));
|
||||
return raws.map(toComparableDto);
|
||||
}
|
||||
|
||||
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<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;
|
||||
}>
|
||||
>`
|
||||
const rows = await this.prisma.$queryRaw<PropertyLocation[]>`
|
||||
SELECT
|
||||
ST_Y(location::geometry) AS "latitude",
|
||||
ST_X(location::geometry) AS "longitude",
|
||||
"areaM2",
|
||||
"propertyType",
|
||||
"yearBuilt",
|
||||
"floor",
|
||||
"totalFloors"
|
||||
"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}`);
|
||||
}
|
||||
if (!row) throw new Error(`Property not found: ${propertyId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
private async findComparables(
|
||||
lat: number,
|
||||
lng: number,
|
||||
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,
|
||||
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
|
||||
)
|
||||
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
|
||||
ORDER BY distance_meters ASC LIMIT 20
|
||||
`,
|
||||
lng,
|
||||
lat,
|
||||
radiusMeters,
|
||||
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