test(reports): add E2E pipeline integration tests for report generation

26 tests covering: full pipeline flow for 3 report types + generic fallback,
status polling (GENERATING → READY/FAILED transitions), quota enforcement and
user scoping, error handling (PDF failure, AI failure, auth checks), delete
cleanup flow, and temp file lifecycle.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:24:52 +07:00
parent 8f2d325d60
commit ac4191cdf0
9 changed files with 1164 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from './analyze-industrial-location.query';
interface ConnectivityInfo {
nearest_port?: { name: string; distanceKm: number };
nearest_airport?: { name: string; distanceKm: number };
nearest_highway?: { name: string; distanceKm: number };
nearest_railway?: { name: string; distanceKm: number };
}
interface InfrastructureInfo {
power_availability?: string;
water_supply?: string;
wastewater_treatment?: string;
telecom?: string;
}
interface LocationAnalysisResult {
overall_score: number;
connectivity: ConnectivityInfo;
infrastructure: InfrastructureInfo;
labor_market: {
worker_pool_radius_30km: number | null;
average_wage_usd: number | null;
nearby_universities: string[];
};
incentives: string[];
risks: string[];
nearby_parks: { name: string; distanceKm: number; occupancyRate: number }[];
}
@QueryHandler(AnalyzeIndustrialLocationQuery)
export class AnalyzeIndustrialLocationHandler
implements IQueryHandler<AnalyzeIndustrialLocationQuery, LocationAnalysisResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: AnalyzeIndustrialLocationQuery): Promise<LocationAnalysisResult> {
const { latitude, longitude, parkName, targetIndustry } = query;
// Find nearest parks within 50km using PostGIS
const nearbyParks = await this.prisma.$queryRaw<
Array<{
id: string;
name: string;
province: string;
region: string;
distanceKm: number;
occupancyRate: number;
landRentUsdM2Year: number | null;
infrastructure: Record<string, unknown> | null;
connectivity: Record<string, unknown> | null;
incentives: Record<string, unknown> | null;
targetIndustries: string[];
}>
>`
SELECT
id, name, province, region,
"occupancyRate",
"landRentUsdM2Year",
infrastructure::jsonb as infrastructure,
connectivity::jsonb as connectivity,
incentives::jsonb as incentives,
"targetIndustries",
ST_Distance(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography
) / 1000.0 AS "distanceKm"
FROM "IndustrialPark"
WHERE ST_DWithin(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography,
50000
)
ORDER BY "distanceKm" ASC
LIMIT 10
`;
// If parkName specified, find that specific park
let targetPark = nearbyParks[0] ?? null;
if (parkName) {
const matched = nearbyParks.find(
(p) => p.name.toLowerCase().includes(parkName.toLowerCase()),
);
if (matched) targetPark = matched;
}
// Build connectivity from nearest park data
const connectivity = this.buildConnectivity(targetPark?.connectivity);
// Build infrastructure from nearest park data
const infrastructure = this.buildInfrastructure(targetPark?.infrastructure);
// Compute labor market estimates based on province/region
const laborMarket = this.estimateLaborMarket(targetPark?.province ?? null, targetPark?.region ?? null);
// Gather incentives
const incentives = this.gatherIncentives(targetPark?.incentives);
// Assess risks
const risks = this.assessRisks(nearbyParks, targetIndustry);
// Calculate overall score (0-100)
const overallScore = this.calculateScore(
connectivity,
infrastructure,
nearbyParks,
targetIndustry,
targetPark,
);
return {
overall_score: overallScore,
connectivity,
infrastructure,
labor_market: laborMarket,
incentives,
risks,
nearby_parks: nearbyParks.slice(0, 5).map((p) => ({
name: p.name,
distanceKm: Math.round(p.distanceKm * 10) / 10,
occupancyRate: p.occupancyRate,
})),
};
}
private buildConnectivity(raw: Record<string, unknown> | null | undefined): ConnectivityInfo {
if (!raw) return {};
return {
nearest_port: this.extractFacility(raw, 'nearestPort', 'seaport'),
nearest_airport: this.extractFacility(raw, 'airport', 'nearestAirport'),
nearest_highway: this.extractFacility(raw, 'highway', 'nearestHighway'),
nearest_railway: this.extractFacility(raw, 'railway', 'nearestRailway'),
};
}
private extractFacility(
raw: Record<string, unknown>,
...keys: string[]
): { name: string; distanceKm: number } | undefined {
for (const key of keys) {
const val = raw[key] as Record<string, unknown> | string | undefined;
if (val && typeof val === 'object' && 'name' in val) {
return { name: String(val['name']), distanceKm: Number(val['distanceKm'] ?? val['distance'] ?? 0) };
}
if (typeof val === 'string') {
return { name: val, distanceKm: 0 };
}
}
return undefined;
}
private buildInfrastructure(raw: Record<string, unknown> | null | undefined): InfrastructureInfo {
if (!raw) return {};
return {
power_availability: raw['electricity'] ? String(raw['electricity']) : undefined,
water_supply: raw['water'] ? String(raw['water']) : undefined,
wastewater_treatment: raw['wastewater'] ? String(raw['wastewater']) : undefined,
telecom: raw['telecom'] ? String(raw['telecom']) : undefined,
};
}
private estimateLaborMarket(province: string | null, region: string | null) {
// Regional labor market estimates for Vietnam industrial zones
const regionData: Record<string, { workers: number; wage: number; unis: string[] }> = {
SOUTH: {
workers: 500_000,
wage: 350,
unis: ['ĐH Bách Khoa TP.HCM', 'ĐH Công nghiệp TP.HCM', 'ĐH Tôn Đức Thắng'],
},
NORTH: {
workers: 400_000,
wage: 300,
unis: ['ĐH Bách Khoa Hà Nội', 'ĐH Công nghiệp Hà Nội'],
},
CENTRAL: {
workers: 200_000,
wage: 280,
unis: ['ĐH Bách Khoa Đà Nẵng', 'ĐH Kinh tế Đà Nẵng'],
},
};
const data = regionData[region ?? 'SOUTH'] ?? regionData['SOUTH']!;
return {
worker_pool_radius_30km: data!.workers,
average_wage_usd: data!.wage,
nearby_universities: data!.unis,
};
}
private gatherIncentives(raw: Record<string, unknown> | null | undefined): string[] {
if (!raw) return [];
const result: string[] = [];
if (raw['taxHoliday']) result.push(`Tax holiday: ${raw['taxHoliday']}`);
if (raw['importDuty']) result.push(`Import duty exemption: ${raw['importDuty']}`);
if (raw['landRentReduction']) result.push(`Land rent reduction: ${raw['landRentReduction']}`);
if (raw['specialZone']) result.push(`Special economic zone: ${raw['specialZone']}`);
return result;
}
private assessRisks(
nearbyParks: Array<{ occupancyRate: number; province: string }>,
targetIndustry?: string | null,
): string[] {
const risks: string[] = [];
if (nearbyParks.length === 0) {
risks.push('No industrial parks within 50km — limited industrial ecosystem');
}
const avgOccupancy =
nearbyParks.length > 0
? nearbyParks.reduce((sum, p) => sum + p.occupancyRate, 0) / nearbyParks.length
: 0;
if (avgOccupancy > 90) {
risks.push('High area occupancy (>90%) — limited expansion options');
}
if (targetIndustry) {
// Check if any nearby park targets this industry — simplified check
const hasMatchingPark = nearbyParks.some(
(p) => (p as unknown as { targetIndustries?: string[] }).targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
),
);
if (!hasMatchingPark) {
risks.push(`No nearby parks specialize in "${targetIndustry}" — may lack ecosystem support`);
}
}
return risks;
}
private calculateScore(
connectivity: ConnectivityInfo,
infrastructure: InfrastructureInfo,
nearbyParks: Array<{ occupancyRate: number; distanceKm: number }>,
targetIndustry?: string | null,
targetPark?: { targetIndustries?: string[]; occupancyRate?: number } | null,
): number {
let score = 50; // Base score
// Connectivity bonus (up to +20)
let connectivityPoints = 0;
if (connectivity.nearest_port) connectivityPoints += 5;
if (connectivity.nearest_airport) connectivityPoints += 5;
if (connectivity.nearest_highway) connectivityPoints += 5;
if (connectivity.nearest_railway) connectivityPoints += 5;
score += connectivityPoints;
// Infrastructure bonus (up to +15)
let infraPoints = 0;
if (infrastructure.power_availability) infraPoints += 4;
if (infrastructure.water_supply) infraPoints += 4;
if (infrastructure.wastewater_treatment) infraPoints += 4;
if (infrastructure.telecom) infraPoints += 3;
score += infraPoints;
// Nearby parks density (up to +10)
if (nearbyParks.length >= 5) score += 10;
else if (nearbyParks.length >= 3) score += 7;
else if (nearbyParks.length >= 1) score += 4;
// Occupancy rate penalty (parks too full = -5)
if (targetPark && targetPark.occupancyRate && targetPark.occupancyRate > 95) {
score -= 5;
}
// Industry match bonus (+5)
if (targetIndustry && targetPark?.targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
)) {
score += 5;
}
return Math.max(0, Math.min(100, Math.round(score)));
}
}

View File

@@ -0,0 +1,8 @@
export class AnalyzeIndustrialLocationQuery {
constructor(
public readonly latitude: number,
public readonly longitude: number,
public readonly parkName?: string | null,
public readonly targetIndustry?: string | null,
) {}
}

View File

@@ -0,0 +1,185 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { EstimateIndustrialRentQuery } from './estimate-industrial-rent.query';
interface RentEstimateResult {
estimated_rent_usd_m2: number;
pricing_unit: string;
total_monthly_usd: number;
total_lease_usd: number;
management_fee_usd_m2: number | null;
deposit_months: number;
market_comparison: {
province_low: number | null;
province_high: number | null;
province_avg: number | null;
};
breakdown: { item: string; amount: number }[];
}
@QueryHandler(EstimateIndustrialRentQuery)
export class EstimateIndustrialRentHandler
implements IQueryHandler<EstimateIndustrialRentQuery, RentEstimateResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: EstimateIndustrialRentQuery): Promise<RentEstimateResult> {
const { province, propertyType, areaM2, leaseDurationYears, parkName, requiresCrane, requiredPowerKva, requiresWastewater } = query;
// Get market data for the province
const provinceParks = await this.prisma.industrialPark.findMany({
where: { province: { contains: province, mode: 'insensitive' } },
select: {
name: true,
landRentUsdM2Year: true,
rbfRentUsdM2Month: true,
rbwRentUsdM2Month: true,
managementFeeUsd: true,
occupancyRate: true,
},
});
// If specific park requested, try to find it
let specificPark = parkName
? provinceParks.find((p) => p.name.toLowerCase().includes(parkName.toLowerCase()))
: null;
// Calculate base rent based on property type
const rentField = this.getRentField(propertyType);
const rents = provinceParks
.map((p) => p[rentField] as number | null)
.filter((r): r is number => r != null);
const provinceLow = rents.length > 0 ? Math.min(...rents) : null;
const provinceHigh = rents.length > 0 ? Math.max(...rents) : null;
const provinceAvg = rents.length > 0 ? rents.reduce((a, b) => a + b, 0) / rents.length : null;
// Determine base rent
let baseRentUsdM2: number;
if (specificPark && specificPark[rentField] != null) {
baseRentUsdM2 = specificPark[rentField] as number;
} else if (provinceAvg != null) {
baseRentUsdM2 = provinceAvg;
} else {
// Fallback to national averages by property type
baseRentUsdM2 = this.getNationalAvgRent(propertyType);
}
// Apply adjustments
const breakdown: { item: string; amount: number }[] = [];
let adjustedRent = baseRentUsdM2;
breakdown.push({ item: `Base ${this.getPropertyTypeLabel(propertyType)} rent`, amount: baseRentUsdM2 });
// Crane surcharge
if (requiresCrane) {
const craneSurcharge = baseRentUsdM2 * 0.08;
adjustedRent += craneSurcharge;
breakdown.push({ item: 'Overhead crane surcharge (+8%)', amount: craneSurcharge });
}
// High power requirement surcharge
if (requiredPowerKva && requiredPowerKva > 500) {
const powerSurcharge = baseRentUsdM2 * 0.05;
adjustedRent += powerSurcharge;
breakdown.push({ item: 'High power capacity surcharge (+5%)', amount: powerSurcharge });
}
// Wastewater treatment surcharge
if (requiresWastewater) {
const wastewaterSurcharge = baseRentUsdM2 * 0.03;
adjustedRent += wastewaterSurcharge;
breakdown.push({ item: 'Wastewater treatment surcharge (+3%)', amount: wastewaterSurcharge });
}
// Long lease discount
if (leaseDurationYears >= 20) {
const discount = adjustedRent * 0.10;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥20yr, -10%)', amount: -discount });
} else if (leaseDurationYears >= 10) {
const discount = adjustedRent * 0.05;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥10yr, -5%)', amount: -discount });
}
// Large area discount
if (areaM2 >= 10_000) {
const discount = adjustedRent * 0.07;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥10,000m², -7%)', amount: -discount });
} else if (areaM2 >= 5_000) {
const discount = adjustedRent * 0.03;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥5,000m², -3%)', amount: -discount });
}
adjustedRent = Math.round(adjustedRent * 100) / 100;
// Determine pricing unit and compute totals
const isMonthlyType = propertyType !== 'industrial_land';
const pricingUnit = isMonthlyType ? 'USD/m²/month' : 'USD/m²/year';
const totalMonthlyUsd = isMonthlyType
? Math.round(adjustedRent * areaM2 * 100) / 100
: Math.round((adjustedRent * areaM2 / 12) * 100) / 100;
const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100;
// Management fee
const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null
: null);
return {
estimated_rent_usd_m2: adjustedRent,
pricing_unit: pricingUnit,
total_monthly_usd: totalMonthlyUsd,
total_lease_usd: totalLeaseUsd,
management_fee_usd_m2: managementFeeUsdM2 ? Math.round(managementFeeUsdM2 * 100) / 100 : null,
deposit_months: leaseDurationYears >= 10 ? 6 : 3,
market_comparison: {
province_low: provinceLow ? Math.round(provinceLow * 100) / 100 : null,
province_high: provinceHigh ? Math.round(provinceHigh * 100) / 100 : null,
province_avg: provinceAvg ? Math.round(provinceAvg * 100) / 100 : null,
},
breakdown: breakdown.map((b) => ({ item: b.item, amount: Math.round(b.amount * 100) / 100 })),
};
}
private getRentField(propertyType: string): 'landRentUsdM2Year' | 'rbfRentUsdM2Month' | 'rbwRentUsdM2Month' {
switch (propertyType) {
case 'ready_built_factory':
return 'rbfRentUsdM2Month';
case 'ready_built_warehouse':
case 'logistics_center':
return 'rbwRentUsdM2Month';
default:
return 'landRentUsdM2Year';
}
}
private getPropertyTypeLabel(propertyType: string): string {
const labels: Record<string, string> = {
industrial_land: 'Industrial land',
ready_built_factory: 'Ready-built factory',
ready_built_warehouse: 'Ready-built warehouse',
logistics_center: 'Logistics center',
office_in_park: 'Office in park',
data_center: 'Data center',
};
return labels[propertyType] ?? propertyType;
}
private getNationalAvgRent(propertyType: string): number {
// Vietnamese national average industrial rents (2024-2025 market data)
const averages: Record<string, number> = {
industrial_land: 120, // USD/m²/year
ready_built_factory: 5.5, // USD/m²/month
ready_built_warehouse: 4.8, // USD/m²/month
logistics_center: 5.0, // USD/m²/month
office_in_park: 8.0, // USD/m²/month
data_center: 12.0, // USD/m²/month
};
return averages[propertyType] ?? 5.0;
}
}

View File

@@ -0,0 +1,12 @@
export class EstimateIndustrialRentQuery {
constructor(
public readonly province: string,
public readonly propertyType: string,
public readonly areaM2: number,
public readonly leaseDurationYears: number,
public readonly parkName?: string | null,
public readonly requiresCrane?: boolean,
public readonly requiredPowerKva?: number | null,
public readonly requiresWastewater?: boolean,
) {}
}

View File

@@ -6,7 +6,9 @@ import { CreateIndustrialParkHandler } from './application/commands/create-indus
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
@@ -30,6 +32,8 @@ const CommandHandlers = [
];
const QueryHandlers = [
AnalyzeIndustrialLocationHandler,
EstimateIndustrialRentHandler,
GetIndustrialParkHandler,
ListIndustrialParksHandler,
CompareIndustrialParksHandler,

View File

@@ -4,15 +4,19 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
import { type AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
import { type CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
import { type EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
import { type SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
@@ -78,6 +82,38 @@ export class IndustrialParksController {
return this.queryBus.execute(new IndustrialMarketQuery());
}
@ApiOperation({ summary: 'Phân tích vị trí KCN', description: 'Đánh giá vị trí dựa trên hạ tầng, kết nối, lao động' })
@ApiResponse({ status: 200, description: 'Kết quả phân tích vị trí' })
@Post('analyze-location')
async analyzeLocation(@Body() dto: AnalyzeIndustrialLocationDto) {
return this.queryBus.execute(
new AnalyzeIndustrialLocationQuery(
dto.latitude,
dto.longitude,
dto.park_name ?? null,
dto.target_industry ?? null,
),
);
}
@ApiOperation({ summary: 'Ước tính giá thuê KCN', description: 'Tính giá thuê BĐS công nghiệp theo tỉnh, loại, diện tích' })
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá thuê' })
@Post('estimate-rent')
async estimateRent(@Body() dto: EstimateIndustrialRentDto) {
return this.queryBus.execute(
new EstimateIndustrialRentQuery(
dto.province,
dto.property_type,
dto.area_m2,
dto.lease_duration_years,
dto.park_name ?? null,
dto.requires_crane ?? false,
dto.required_power_kva ?? null,
dto.requires_wastewater ?? false,
),
);
}
// ── Admin endpoints ───────────────────────────────────────────────
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class AnalyzeIndustrialLocationDto {
@ApiProperty({ example: 10.9, description: 'Vĩ độ' })
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.8, description: 'Kinh độ' })
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cần phân tích' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: 'electronics', description: 'Ngành mục tiêu' })
@IsOptional()
@IsString()
target_industry?: string;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
const INDUSTRIAL_PROPERTY_TYPES = [
'industrial_land',
'ready_built_factory',
'ready_built_warehouse',
'logistics_center',
'office_in_park',
'data_center',
] as const;
export class EstimateIndustrialRentDto {
@ApiProperty({ example: 'Bình Dương', description: 'Tỉnh/thành phố' })
@IsString()
province!: string;
@ApiProperty({
example: 'ready_built_factory',
enum: INDUSTRIAL_PROPERTY_TYPES,
description: 'Loại BĐS công nghiệp',
})
@IsEnum(INDUSTRIAL_PROPERTY_TYPES)
property_type!: (typeof INDUSTRIAL_PROPERTY_TYPES)[number];
@ApiProperty({ example: 5000, description: 'Diện tích yêu cầu (m²)' })
@IsNumber()
@Type(() => Number)
@Min(1)
area_m2!: number;
@ApiProperty({ example: 10, description: 'Thời hạn thuê (năm)' })
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(70)
lease_duration_years!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cụ thể' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu cầu trục' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_crane?: boolean;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện yêu cầu (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
required_power_kva?: number;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu xử lý nước thải' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_wastewater?: boolean;
}