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:
Ho Ngoc Hai
2026-04-12 21:12:56 +07:00
parent 97a9541fde
commit aca4fd37cb
9 changed files with 511 additions and 545 deletions

View File

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

View File

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