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:
@@ -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 = [
|
||||
|
||||
@@ -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<typeof vi.fn> };
|
||||
let mockLogger: { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<IndustrialValuationQuery> {
|
||||
constructor(
|
||||
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: IndustrialValuationQuery): Promise<IndustrialValuationDto> {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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<AiPredictResponse>;
|
||||
predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse>;
|
||||
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
@@ -66,6 +116,10 @@ export class AiServiceClient implements IAiServiceClient {
|
||||
return this.post<AiPredictResponse>('/avm/predict', req);
|
||||
}
|
||||
|
||||
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
|
||||
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req);
|
||||
}
|
||||
|
||||
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
|
||||
return this.post<AiModerationResponse>('/moderation/check', req);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user