feat(api): add industrial, transfer, and reports backend modules

Add three new NestJS modules following DDD/CQRS architecture:
- Industrial: KCN (industrial park) management with PostGIS geo queries, Typesense search, and market statistics
- Transfer: Furniture/premises transfer listings with AI-powered price estimation and depreciation modeling
- Reports: Async AI report generation via BullMQ with Claude narrative service, PDF generation, and macro data integration

Includes Prisma schema models, migrations, seed scripts, and app.module wiring with BullMQ Redis config.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:11:16 +07:00
parent 7ce651fce5
commit deb04989de
123 changed files with 8260 additions and 12 deletions

View File

@@ -0,0 +1,156 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
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 CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
import { type SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
@ApiTags('industrial-parks')
@Controller('industrial')
export class IndustrialParksController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Public endpoints ──────────────────────────────────────────────
@ApiOperation({ summary: 'Danh sách KCN', description: 'Tìm kiếm và lọc khu công nghiệp' })
@ApiResponse({ status: 200, description: 'Danh sách KCN phân trang' })
@Get('parks')
async listParks(@Query() dto: SearchIndustrialParksDto) {
return this.queryBus.execute(
new ListIndustrialParksQuery(
dto.q,
dto.province,
dto.region,
dto.status,
dto.minAreaHa,
dto.maxRentUsdM2,
dto.targetIndustry,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Chi tiết KCN', description: 'Xem chi tiết KCN theo slug hoặc ID' })
@ApiResponse({ status: 200, description: 'Thông tin chi tiết KCN' })
@ApiResponse({ status: 404, description: 'Không tìm thấy KCN' })
@Get('parks/:slugOrId')
async getPark(@Param('slugOrId') slugOrId: string) {
const result = await this.queryBus.execute(new GetIndustrialParkQuery(slugOrId));
if (!result) {
throw new NotFoundException('Industrial park', slugOrId);
}
return result;
}
@ApiOperation({ summary: 'So sánh KCN', description: 'So sánh 2-5 KCN' })
@ApiResponse({ status: 200, description: 'Dữ liệu so sánh KCN' })
@Post('parks/compare')
async compareParks(@Body() dto: CompareIndustrialParksDto) {
return this.queryBus.execute(new CompareIndustrialParksQuery(dto.ids));
}
@ApiOperation({ summary: 'Thống kê KCN', description: 'Tổng quan thống kê tất cả KCN' })
@ApiResponse({ status: 200, description: 'Dữ liệu thống kê' })
@Get('parks/stats')
async getStats() {
return this.queryBus.execute(new IndustrialParkStatsQuery());
}
@ApiOperation({ summary: 'Thị trường KCN', description: 'Dữ liệu thị trường BĐS công nghiệp' })
@ApiResponse({ status: 200, description: 'Dữ liệu thị trường' })
@Get('market')
async getMarket() {
return this.queryBus.execute(new IndustrialMarketQuery());
}
// ── Admin endpoints ───────────────────────────────────────────────
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })
@ApiResponse({ status: 201, description: 'KCN đã tạo' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Post('parks')
async createPark(@Body() dto: CreateIndustrialParkDto) {
return this.commandBus.execute(
new CreateIndustrialParkCommand(
dto.name,
dto.nameEn ?? null,
dto.slug,
dto.developer,
dto.operator ?? null,
dto.status,
dto.latitude,
dto.longitude,
dto.address,
dto.district,
dto.province,
dto.region,
dto.totalAreaHa,
dto.leasableAreaHa,
dto.occupancyRate,
dto.remainingAreaHa,
dto.tenantCount ?? 0,
dto.establishedYear ?? null,
dto.landRentUsdM2Year ?? null,
dto.rbfRentUsdM2Month ?? null,
dto.rbwRentUsdM2Month ?? null,
dto.managementFeeUsd ?? null,
dto.infrastructure ?? null,
dto.connectivity ?? null,
dto.incentives ?? null,
dto.targetIndustries,
dto.description ?? null,
dto.descriptionEn ?? null,
),
);
}
@ApiOperation({ summary: 'Cập nhật KCN (admin)', description: 'Cập nhật thông tin KCN' })
@ApiResponse({ status: 200, description: 'KCN đã cập nhật' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Patch('parks/:id')
async updatePark(@Param('id') id: string, @Body() dto: UpdateIndustrialParkDto) {
return this.commandBus.execute(
new UpdateIndustrialParkCommand(
id,
dto.name,
dto.nameEn,
dto.developer,
dto.operator,
dto.status,
dto.occupancyRate,
dto.remainingAreaHa,
dto.tenantCount,
dto.landRentUsdM2Year,
dto.rbfRentUsdM2Month,
dto.rbwRentUsdM2Month,
dto.managementFeeUsd,
dto.infrastructure,
dto.connectivity,
dto.incentives,
dto.targetIndustries,
dto.description,
dto.descriptionEn,
dto.isVerified,
),
);
}
}