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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 18:01:23 +07:00
parent 9eaec46a37
commit 0dda2bffdb
9 changed files with 599 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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