- Remove `type` modifier from imports used as DI constructor params across ~235 files (@Injectable, @Controller, @Module, @Catch, @CommandHandler, @QueryHandler, @EventsHandler, @WebSocketGateway). TypeScript emitDecoratorMetadata strips type-only imports, leaving Reflect.metadata with Function placeholder and breaking Nest DI. - Fix controllers: DTOs used with @Body/@Query/@Param must be runtime imports so ValidationPipe can whitelist properties. Previously returned 400 "property X should not exist" on every request. - Register ProjectsModule in AppModule (was defined but never wired). - Add approve()/reject() methods to TransferListingEntity referenced by ModerateTransferListingHandler. - Export BankTransferConfirmedEvent from payments barrel for subscription activation handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { type PropertyType } from '@prisma/client';
|
|
import { PrismaService } from '@modules/shared';
|
|
import {
|
|
type IAVMService,
|
|
type AVMParams,
|
|
type ValuationResult,
|
|
type Comparable,
|
|
type BatchValuationItem,
|
|
type BatchValuationResult,
|
|
} 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 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> {
|
|
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(toComparableDto),
|
|
modelVersion: MODEL_VERSION,
|
|
};
|
|
}
|
|
|
|
const { pricePerM2, confidence } = calculateWeightedPrice(
|
|
comparables, resolved.areaM2, resolved.propertyType,
|
|
resolved.yearBuilt, resolved.floor, resolved.totalFloors,
|
|
);
|
|
const estimatedPrice = BigInt(Math.round(pricePerM2 * resolved.areaM2));
|
|
|
|
return {
|
|
estimatedPrice: estimatedPrice.toString(),
|
|
confidence,
|
|
pricePerM2,
|
|
comparables: comparables.map(toComparableDto),
|
|
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(toComparableDto);
|
|
}
|
|
|
|
async estimateBatch(items: BatchValuationItem[]): Promise<BatchValuationResult[]> {
|
|
return Promise.all(
|
|
items.map(async (item) => {
|
|
try {
|
|
const valuation = await this.estimateValue({ propertyId: item.propertyId });
|
|
return { propertyId: item.propertyId, valuation };
|
|
} catch {
|
|
return { propertyId: item.propertyId, valuation: null, error: 'Lỗi định giá' };
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
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<PropertyLocation[]>`
|
|
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,
|
|
);
|
|
}
|
|
}
|