From 0dda2bffdb3cecd16a60d9e64629ec065dda98c9 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 18:01:23 +0700 Subject: [PATCH] feat(api): add POST /avm/industrial endpoint for industrial rent estimation Wire NestJS controller to Python AI service's industrial AVM. Adds CQRS query/handler, Swagger-annotated DTOs, AI client method, and 7 unit tests covering parameter mapping, response camelCase conversion, and error handling. Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 2 + .../industrial-valuation.handler.spec.ts | 141 ++++++++++++++++++ .../industrial-valuation.handler.ts | 106 +++++++++++++ .../industrial-valuation.query.ts | 24 +++ .../services/ai-service.client.ts | 54 +++++++ .../__tests__/avm.controller.spec.ts | 92 ++++++++++++ .../controllers/avm.controller.ts | 40 +++++ .../analytics/presentation/dto/index.ts | 1 + .../dto/industrial-valuation.dto.ts | 139 +++++++++++++++++ 9 files changed, 599 insertions(+) create mode 100644 apps/api/src/modules/analytics/application/queries/industrial-valuation/__tests__/industrial-valuation.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.handler.ts create mode 100644 apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.query.ts create mode 100644 apps/api/src/modules/analytics/presentation/dto/industrial-valuation.dto.ts diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index ec1b47b..0c05ced 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -5,6 +5,7 @@ import { TrackEventHandler } from './application/commands/track-event/track-even import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler'; import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler'; import { BatchValuationHandler } from './application/queries/batch-valuation/batch-valuation.handler'; +import { IndustrialValuationHandler } from './application/queries/industrial-valuation/industrial-valuation.handler'; import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler'; import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler'; import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler'; @@ -43,6 +44,7 @@ const QueryHandlers = [ ValuationHistoryHandler, ValuationComparisonHandler, GetNeighborhoodScoreHandler, + IndustrialValuationHandler, ]; const EventHandlers = [ diff --git a/apps/api/src/modules/analytics/application/queries/industrial-valuation/__tests__/industrial-valuation.handler.spec.ts b/apps/api/src/modules/analytics/application/queries/industrial-valuation/__tests__/industrial-valuation.handler.spec.ts new file mode 100644 index 0000000..088c802 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/industrial-valuation/__tests__/industrial-valuation.handler.spec.ts @@ -0,0 +1,141 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type IAiServiceClient } from '../../../../infrastructure/services/ai-service.client'; +import { IndustrialValuationHandler } from '../industrial-valuation.handler'; +import { IndustrialValuationQuery } from '../industrial-valuation.query'; + +describe('IndustrialValuationHandler', () => { + let handler: IndustrialValuationHandler; + let mockAiClient: { predictIndustrial: ReturnType }; + let mockLogger: { error: ReturnType }; + + const query = new IndustrialValuationQuery( + 'Bình Dương', + 'south', + 0.85, + 500, + 10, + 25, + 40, + 5, + 'factory', + 5000, + 10, + 3, + 2000, + 0.6, + 4, + 'general_industrial', + 0.7, + 3000, + 8000000, + 0.75, + ); + + const aiResponse = { + estimated_rent_usd_m2: 5.2, + confidence: 0.65, + rent_range_low_usd_m2: 4.16, + rent_range_high_usd_m2: 6.24, + annual_rent_usd_m2: 62.4, + total_monthly_rent_usd: 26000, + comparables: [ + { + park_name: 'VSIP I', + province: 'Bình Dương', + property_type: 'factory', + area_m2: 5000, + rent_usd_m2: 5.2, + similarity_score: 0.85, + }, + ], + drivers: [ + { feature: 'province_baseline', importance: 0.16 }, + { feature: 'property_type', importance: 0.12 }, + ], + model_version: 'heuristic-v1', + }; + + beforeEach(() => { + mockAiClient = { predictIndustrial: vi.fn() }; + mockLogger = { error: vi.fn() }; + handler = new IndustrialValuationHandler( + mockAiClient as unknown as IAiServiceClient, + mockLogger as any, + ); + }); + + it('calls AI service with correct snake_case parameters', async () => { + mockAiClient.predictIndustrial.mockResolvedValue(aiResponse); + + await handler.execute(query); + + expect(mockAiClient.predictIndustrial).toHaveBeenCalledWith({ + province: 'Bình Dương', + region: 'south', + park_occupancy_rate: 0.85, + park_area_ha: 500, + park_age_years: 10, + distance_to_port_km: 25, + distance_to_airport_km: 40, + distance_to_highway_km: 5, + property_type: 'factory', + area_m2: 5000, + ceiling_height_m: 10, + floor_load_ton_m2: 3, + power_capacity_kva: 2000, + building_coverage: 0.6, + loading_docks: 4, + zoning: 'general_industrial', + industry_demand_index: 0.7, + fdi_province_musd: 3000, + labor_cost_province_vnd: 8000000, + logistics_connectivity_score: 0.75, + }); + }); + + it('maps AI response to camelCase DTO', async () => { + mockAiClient.predictIndustrial.mockResolvedValue(aiResponse); + + const result = await handler.execute(query); + + expect(result.estimatedRentUsdM2).toBe(5.2); + expect(result.confidence).toBe(0.65); + expect(result.rentRangeLowUsdM2).toBe(4.16); + expect(result.rentRangeHighUsdM2).toBe(6.24); + expect(result.annualRentUsdM2).toBe(62.4); + expect(result.totalMonthlyRentUsd).toBe(26000); + expect(result.modelVersion).toBe('heuristic-v1'); + }); + + it('maps comparable properties to camelCase', async () => { + mockAiClient.predictIndustrial.mockResolvedValue(aiResponse); + + const result = await handler.execute(query); + + expect(result.comparables).toHaveLength(1); + expect(result.comparables[0]).toEqual({ + parkName: 'VSIP I', + province: 'Bình Dương', + propertyType: 'factory', + areaM2: 5000, + rentUsdM2: 5.2, + similarityScore: 0.85, + }); + }); + + it('maps drivers array', async () => { + mockAiClient.predictIndustrial.mockResolvedValue(aiResponse); + + const result = await handler.execute(query); + + expect(result.drivers).toHaveLength(2); + expect(result.drivers[0]).toEqual({ feature: 'province_baseline', importance: 0.16 }); + }); + + it('throws InternalServerErrorException on AI service failure', async () => { + mockAiClient.predictIndustrial.mockRejectedValue(new Error('AI service down')); + + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.handler.ts new file mode 100644 index 0000000..343b384 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.handler.ts @@ -0,0 +1,106 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, type LoggerService } from '@modules/shared'; +import { + AI_SERVICE_CLIENT, + type IAiServiceClient, + type AiIndustrialPredictResponse, +} from '../../../infrastructure/services/ai-service.client'; +import { IndustrialValuationQuery } from './industrial-valuation.query'; + +export interface IndustrialValuationComparable { + parkName: string; + province: string; + propertyType: string; + areaM2: number; + rentUsdM2: number; + similarityScore: number; +} + +export interface IndustrialValuationDriver { + feature: string; + importance: number; +} + +export interface IndustrialValuationDto { + estimatedRentUsdM2: number; + confidence: number; + rentRangeLowUsdM2: number; + rentRangeHighUsdM2: number; + annualRentUsdM2: number; + totalMonthlyRentUsd: number; + comparables: IndustrialValuationComparable[]; + drivers: IndustrialValuationDriver[]; + modelVersion: string; +} + +function mapResponse(res: AiIndustrialPredictResponse): IndustrialValuationDto { + return { + estimatedRentUsdM2: res.estimated_rent_usd_m2, + confidence: res.confidence, + rentRangeLowUsdM2: res.rent_range_low_usd_m2, + rentRangeHighUsdM2: res.rent_range_high_usd_m2, + annualRentUsdM2: res.annual_rent_usd_m2, + totalMonthlyRentUsd: res.total_monthly_rent_usd, + comparables: res.comparables.map((c) => ({ + parkName: c.park_name, + province: c.province, + propertyType: c.property_type, + areaM2: c.area_m2, + rentUsdM2: c.rent_usd_m2, + similarityScore: c.similarity_score, + })), + drivers: res.drivers.map((d) => ({ + feature: d.feature, + importance: d.importance, + })), + modelVersion: res.model_version, + }; +} + +@QueryHandler(IndustrialValuationQuery) +export class IndustrialValuationHandler implements IQueryHandler { + constructor( + @Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient, + private readonly logger: LoggerService, + ) {} + + async execute(query: IndustrialValuationQuery): Promise { + try { + const response = await this.aiClient.predictIndustrial({ + province: query.province, + region: query.region, + park_occupancy_rate: query.parkOccupancyRate, + park_area_ha: query.parkAreaHa, + park_age_years: query.parkAgeYears, + distance_to_port_km: query.distanceToPortKm, + distance_to_airport_km: query.distanceToAirportKm, + distance_to_highway_km: query.distanceToHighwayKm, + property_type: query.propertyType, + area_m2: query.areaM2, + ceiling_height_m: query.ceilingHeightM, + floor_load_ton_m2: query.floorLoadTonM2, + power_capacity_kva: query.powerCapacityKva, + building_coverage: query.buildingCoverage, + loading_docks: query.loadingDocks, + zoning: query.zoning, + industry_demand_index: query.industryDemandIndex, + fdi_province_musd: query.fdiProvinceMusd, + labor_cost_province_vnd: query.laborCostProvinceVnd, + logistics_connectivity_score: query.logisticsConnectivityScore, + }); + + return mapResponse(response); + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to estimate industrial rent: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể ước tính giá thuê khu công nghiệp. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.query.ts b/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.query.ts new file mode 100644 index 0000000..e846548 --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/industrial-valuation/industrial-valuation.query.ts @@ -0,0 +1,24 @@ +export class IndustrialValuationQuery { + constructor( + public readonly province: string, + public readonly region: string, + public readonly parkOccupancyRate: number, + public readonly parkAreaHa: number, + public readonly parkAgeYears: number, + public readonly distanceToPortKm: number, + public readonly distanceToAirportKm: number, + public readonly distanceToHighwayKm: number, + public readonly propertyType: string, + public readonly areaM2: number, + public readonly ceilingHeightM?: number, + public readonly floorLoadTonM2?: number, + public readonly powerCapacityKva?: number, + public readonly buildingCoverage?: number, + public readonly loadingDocks?: number, + public readonly zoning?: string, + public readonly industryDemandIndex?: number, + public readonly fdiProvinceMusd?: number, + public readonly laborCostProvinceVnd?: number, + public readonly logisticsConnectivityScore?: number, + ) {} +} diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts index d030686..c7dd2e8 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -23,6 +23,55 @@ export interface AiPredictResponse { price_range_high: number; } +export interface AiIndustrialPredictRequest { + province: string; + region: string; + park_occupancy_rate: number; + park_area_ha: number; + park_age_years: number; + distance_to_port_km: number; + distance_to_airport_km: number; + distance_to_highway_km: number; + property_type: string; + area_m2: number; + ceiling_height_m?: number; + floor_load_ton_m2?: number; + power_capacity_kva?: number; + building_coverage?: number; + loading_docks?: number; + zoning?: string; + industry_demand_index?: number; + fdi_province_musd?: number; + labor_cost_province_vnd?: number; + logistics_connectivity_score?: number; +} + +export interface AiIndustrialComparable { + park_name: string; + province: string; + property_type: string; + area_m2: number; + rent_usd_m2: number; + similarity_score: number; +} + +export interface AiIndustrialFeatureImportance { + feature: string; + importance: number; +} + +export interface AiIndustrialPredictResponse { + estimated_rent_usd_m2: number; + confidence: number; + rent_range_low_usd_m2: number; + rent_range_high_usd_m2: number; + annual_rent_usd_m2: number; + total_monthly_rent_usd: number; + comparables: AiIndustrialComparable[]; + drivers: AiIndustrialFeatureImportance[]; + model_version: string; +} + export interface AiModerationRequest { text: string; context?: string; @@ -46,6 +95,7 @@ export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT'); export interface IAiServiceClient { predict(req: AiPredictRequest): Promise; + predictIndustrial(req: AiIndustrialPredictRequest): Promise; moderate(req: AiModerationRequest): Promise; isAvailable(): Promise; } @@ -66,6 +116,10 @@ export class AiServiceClient implements IAiServiceClient { return this.post('/avm/predict', req); } + async predictIndustrial(req: AiIndustrialPredictRequest): Promise { + return this.post('/avm/industrial/predict', req); + } + async moderate(req: AiModerationRequest): Promise { return this.post('/moderation/check', req); } diff --git a/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts b/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts index a3aad2d..d7bb39e 100644 --- a/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts +++ b/apps/api/src/modules/analytics/presentation/__tests__/avm.controller.spec.ts @@ -1,5 +1,6 @@ import { type QueryBus } from '@nestjs/cqrs'; import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; +import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query'; import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; import { AvmController } from '../controllers/avm.controller'; @@ -92,4 +93,95 @@ describe('AvmController', () => { expect(result).toBe(expected); }); }); + + describe('POST /avm/industrial', () => { + const industrialDto = { + province: 'Bình Dương', + region: 'south', + parkOccupancyRate: 0.85, + parkAreaHa: 500, + parkAgeYears: 10, + distanceToPortKm: 25, + distanceToAirportKm: 40, + distanceToHighwayKm: 5, + propertyType: 'factory', + areaM2: 5000, + ceilingHeightM: 10, + loadingDocks: 4, + zoning: 'general_industrial', + }; + + it('dispatches IndustrialValuationQuery with all required fields', async () => { + const expected = { + estimatedRentUsdM2: 5.2, + confidence: 0.65, + rentRangeLowUsdM2: 4.16, + rentRangeHighUsdM2: 6.24, + annualRentUsdM2: 62.4, + totalMonthlyRentUsd: 26000, + comparables: [], + drivers: [], + modelVersion: 'heuristic-v1', + }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.industrialValuation(industrialDto as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new IndustrialValuationQuery( + 'Bình Dương', + 'south', + 0.85, + 500, + 10, + 25, + 40, + 5, + 'factory', + 5000, + 10, + undefined, + undefined, + undefined, + 4, + 'general_industrial', + undefined, + undefined, + undefined, + undefined, + ), + ); + expect(result).toBe(expected); + }); + + it('passes optional fields when provided', async () => { + const fullDto = { + ...industrialDto, + floorLoadTonM2: 3, + powerCapacityKva: 2000, + buildingCoverage: 0.6, + industryDemandIndex: 0.7, + fdiProvinceMusd: 3000, + laborCostProvinceVnd: 8000000, + logisticsConnectivityScore: 0.75, + }; + const expected = { + estimatedRentUsdM2: 5.8, + confidence: 0.72, + comparables: [], + drivers: [], + }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.industrialValuation(fullDto as any); + + const call = mockQueryBus.execute.mock.calls[0]![0] as IndustrialValuationQuery; + expect(call.province).toBe('Bình Dương'); + expect(call.floorLoadTonM2).toBe(3); + expect(call.powerCapacityKva).toBe(2000); + expect(call.buildingCoverage).toBe(0.6); + expect(call.logisticsConnectivityScore).toBe(0.75); + expect(result).toBe(expected); + }); + }); }); diff --git a/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts index 69c0fff..acff2a1 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/avm.controller.ts @@ -14,12 +14,15 @@ import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { type BatchValuationDto as BatchValuationResultDto } from '../../application/queries/batch-valuation/batch-valuation.handler'; import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; +import { type IndustrialValuationDto as IndustrialValuationResultDto } from '../../application/queries/industrial-valuation/industrial-valuation.handler'; +import { IndustrialValuationQuery } from '../../application/queries/industrial-valuation/industrial-valuation.query'; import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler'; import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler'; import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; import { type AvmCompareQueryDto } from '../dto/avm-compare-query.dto'; import { type BatchValuationDto } from '../dto/batch-valuation.dto'; +import { type IndustrialValuationDto } from '../dto/industrial-valuation.dto'; import { type ValuationHistoryDto } from '../dto/valuation-history.dto'; @ApiTags('avm') @@ -83,4 +86,41 @@ export class AvmController { new ValuationComparisonQuery(dto.ids), ); } + + @ApiBearerAuth('JWT') + @EndpointRateLimit({ limit: 10, windowSeconds: 60, keyStrategy: 'user' }) + @UseGuards(EndpointRateLimitGuard, JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') + @Post('industrial') + @ApiOperation({ summary: 'Estimate industrial property rent using AI model' }) + @ApiResponse({ status: 200, description: 'Industrial rent estimation with comparables and drivers' }) + @ApiResponse({ status: 400, description: 'Invalid parameters' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded — max 10 requests per 60s' }) + async industrialValuation(@Body() dto: IndustrialValuationDto): Promise { + return this.queryBus.execute( + new IndustrialValuationQuery( + dto.province, + dto.region, + dto.parkOccupancyRate, + dto.parkAreaHa, + dto.parkAgeYears, + dto.distanceToPortKm, + dto.distanceToAirportKm, + dto.distanceToHighwayKm, + dto.propertyType, + dto.areaM2, + dto.ceilingHeightM, + dto.floorLoadTonM2, + dto.powerCapacityKva, + dto.buildingCoverage, + dto.loadingDocks, + dto.zoning, + dto.industryDemandIndex, + dto.fdiProvinceMusd, + dto.laborCostProvinceVnd, + dto.logisticsConnectivityScore, + ), + ); + } } diff --git a/apps/api/src/modules/analytics/presentation/dto/index.ts b/apps/api/src/modules/analytics/presentation/dto/index.ts index 7141786..2aa905f 100644 --- a/apps/api/src/modules/analytics/presentation/dto/index.ts +++ b/apps/api/src/modules/analytics/presentation/dto/index.ts @@ -7,3 +7,4 @@ export { BatchValuationDto } from './batch-valuation.dto'; export { ValuationHistoryDto } from './valuation-history.dto'; export { ValuationComparisonDto } from './valuation-comparison.dto'; export { AvmCompareQueryDto } from './avm-compare-query.dto'; +export { IndustrialValuationDto } from './industrial-valuation.dto'; diff --git a/apps/api/src/modules/analytics/presentation/dto/industrial-valuation.dto.ts b/apps/api/src/modules/analytics/presentation/dto/industrial-valuation.dto.ts new file mode 100644 index 0000000..c104d50 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/industrial-valuation.dto.ts @@ -0,0 +1,139 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsString, IsNumber, Min, Max, IsOptional } from 'class-validator'; + +export class IndustrialValuationDto { + @ApiProperty({ description: 'Province name (e.g. Bình Dương)', example: 'Bình Dương' }) + @IsString() + province!: string; + + @ApiProperty({ description: 'Region: south, north, central, mekong_delta', example: 'south' }) + @IsString() + region!: string; + + @ApiProperty({ description: 'Park occupancy rate (0-1)', example: 0.85 }) + @IsNumber() + @Type(() => Number) + @Min(0) + @Max(1) + parkOccupancyRate!: number; + + @ApiProperty({ description: 'Total park area in hectares', example: 500 }) + @IsNumber() + @Type(() => Number) + @Min(0) + parkAreaHa!: number; + + @ApiProperty({ description: 'Park age in years', example: 10 }) + @IsNumber() + @Type(() => Number) + @Min(0) + parkAgeYears!: number; + + @ApiProperty({ description: 'Distance to nearest seaport in km', example: 25 }) + @IsNumber() + @Type(() => Number) + @Min(0) + distanceToPortKm!: number; + + @ApiProperty({ description: 'Distance to nearest airport in km', example: 40 }) + @IsNumber() + @Type(() => Number) + @Min(0) + distanceToAirportKm!: number; + + @ApiProperty({ description: 'Distance to nearest highway in km', example: 5 }) + @IsNumber() + @Type(() => Number) + @Min(0) + distanceToHighwayKm!: number; + + @ApiProperty({ + description: 'Industrial property type', + example: 'factory', + enum: ['warehouse', 'factory', 'ready_built_factory', 'ready_built_warehouse', 'open_yard', 'office_in_park'], + }) + @IsString() + propertyType!: string; + + @ApiProperty({ description: 'Leasable area in m²', example: 5000 }) + @IsNumber() + @Type(() => Number) + @Min(1) + areaM2!: number; + + @ApiPropertyOptional({ description: 'Ceiling height in meters', example: 10 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + ceilingHeightM?: number; + + @ApiPropertyOptional({ description: 'Floor load capacity in tons/m²', example: 3 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + floorLoadTonM2?: number; + + @ApiPropertyOptional({ description: 'Power capacity in kVA', example: 2000 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + powerCapacityKva?: number; + + @ApiPropertyOptional({ description: 'Building coverage ratio (0-1)', example: 0.6 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + @Max(1) + buildingCoverage?: number; + + @ApiPropertyOptional({ description: 'Number of loading docks', example: 4 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + loadingDocks?: number; + + @ApiPropertyOptional({ + description: 'Industrial zoning category', + example: 'general_industrial', + enum: ['general_industrial', 'heavy_industrial', 'light_industrial', 'logistics', 'free_trade_zone', 'high_tech'], + }) + @IsOptional() + @IsString() + zoning?: string; + + @ApiPropertyOptional({ description: 'Local industry demand index (0-1)', example: 0.7 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + @Max(1) + industryDemandIndex?: number; + + @ApiPropertyOptional({ description: 'Province FDI inflow in million USD', example: 3000 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + fdiProvinceMusd?: number; + + @ApiPropertyOptional({ description: 'Average province labor cost in VND/month', example: 8000000 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + laborCostProvinceVnd?: number; + + @ApiPropertyOptional({ description: 'Logistics connectivity score (0-1)', example: 0.7 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + @Max(1) + logisticsConnectivityScore?: number; +}