Files
goodgo-platform/apps/api/src/modules/analytics/infrastructure/services/prisma-avm.service.ts
Ho Ngoc Hai 312532b1cb fix(api): resolve NestJS DI + ValidationPipe bugs from type-only imports
- 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>
2026-04-18 21:50:30 +07:00

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