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:
@@ -0,0 +1,155 @@
|
||||
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 { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
||||
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
|
||||
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command';
|
||||
import { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query';
|
||||
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
|
||||
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
|
||||
import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto';
|
||||
import { type EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto';
|
||||
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
|
||||
import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.dto';
|
||||
|
||||
@ApiTags('transfer')
|
||||
@Controller('transfer')
|
||||
export class TransferController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
// ── Public endpoints ──────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Danh sách sang nhượng', description: 'Tìm kiếm và lọc tin sang nhượng' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách tin sang nhượng phân trang' })
|
||||
@Get('listings')
|
||||
async listListings(@Query() dto: SearchTransferListingsDto) {
|
||||
return this.queryBus.execute(
|
||||
new ListTransferListingsQuery(
|
||||
dto.q,
|
||||
dto.category,
|
||||
dto.status,
|
||||
dto.district,
|
||||
dto.city,
|
||||
dto.minPrice,
|
||||
dto.maxPrice,
|
||||
undefined, // sellerId — only used for "my listings" queries
|
||||
dto.page ?? 1,
|
||||
dto.limit ?? 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Chi tiết tin sang nhượng', description: 'Xem chi tiết tin sang nhượng theo ID' })
|
||||
@ApiResponse({ status: 200, description: 'Thông tin chi tiết tin sang nhượng' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy tin sang nhượng' })
|
||||
@Get('listings/:id')
|
||||
async getListing(@Param('id') id: string) {
|
||||
const result = await this.queryBus.execute(new GetTransferListingQuery(id));
|
||||
if (!result) {
|
||||
throw new NotFoundException('Transfer listing', id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Thống kê sang nhượng', description: 'Tổng quan thống kê tin sang nhượng' })
|
||||
@ApiResponse({ status: 200, description: 'Dữ liệu thống kê' })
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
return this.queryBus.execute(new TransferStatsQuery());
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Ước tính giá AI', description: 'Ước tính giá trị nội thất/thiết bị dựa trên khấu hao, thương hiệu và tình trạng' })
|
||||
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá' })
|
||||
@Post('estimate')
|
||||
async estimatePrices(@Body() dto: EstimateTransferPricesDto) {
|
||||
return this.commandBus.execute(
|
||||
new EstimateTransferPricesCommand(dto.items),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Authenticated endpoints ───────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
|
||||
@ApiResponse({ status: 201, description: 'Tin sang nhượng đã tạo' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('listings')
|
||||
async createListing(
|
||||
@CurrentUser() user: { sub: string },
|
||||
@Body() dto: CreateTransferListingDto,
|
||||
) {
|
||||
return this.commandBus.execute(
|
||||
new CreateTransferListingCommand(
|
||||
user.sub,
|
||||
dto.category,
|
||||
dto.title,
|
||||
dto.description ?? null,
|
||||
dto.address,
|
||||
dto.ward ?? null,
|
||||
dto.district,
|
||||
dto.city,
|
||||
dto.latitude,
|
||||
dto.longitude,
|
||||
BigInt(Math.round(dto.askingPriceVND)),
|
||||
dto.pricingSource ?? 'MANUAL',
|
||||
dto.isNegotiable ?? true,
|
||||
dto.areaM2 ?? null,
|
||||
dto.monthlyRentVND ? BigInt(Math.round(dto.monthlyRentVND)) : null,
|
||||
dto.depositMonths ?? null,
|
||||
dto.remainingLeaseMo ?? null,
|
||||
dto.businessType ?? null,
|
||||
dto.footTraffic ?? null,
|
||||
dto.contactPhone ?? null,
|
||||
dto.contactName ?? null,
|
||||
(dto.items ?? []).map((item) => ({
|
||||
name: item.name,
|
||||
brand: item.brand,
|
||||
modelName: item.modelName,
|
||||
category: item.category,
|
||||
condition: item.condition,
|
||||
purchaseYear: item.purchaseYear,
|
||||
originalPriceVND: item.originalPriceVND != null ? BigInt(Math.round(item.originalPriceVND)) : undefined,
|
||||
askingPriceVND: BigInt(Math.round(item.askingPriceVND)),
|
||||
quantity: item.quantity,
|
||||
dimensions: item.dimensions,
|
||||
notes: item.notes,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Cập nhật tin sang nhượng', description: 'Cập nhật thông tin tin sang nhượng' })
|
||||
@ApiResponse({ status: 200, description: 'Tin sang nhượng đã cập nhật' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('listings/:id')
|
||||
async updateListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTransferListingDto,
|
||||
) {
|
||||
return this.commandBus.execute(
|
||||
new UpdateTransferListingCommand(
|
||||
id,
|
||||
dto.title,
|
||||
dto.description,
|
||||
dto.status,
|
||||
dto.askingPriceVND ? BigInt(Math.round(dto.askingPriceVND)) : undefined,
|
||||
dto.isNegotiable,
|
||||
dto.areaM2,
|
||||
dto.monthlyRentVND !== undefined ? BigInt(Math.round(dto.monthlyRentVND)) : undefined,
|
||||
dto.depositMonths,
|
||||
dto.remainingLeaseMo,
|
||||
dto.businessType,
|
||||
dto.footTraffic,
|
||||
dto.contactPhone,
|
||||
dto.contactName,
|
||||
dto.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user