feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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 { CreateProjectCommand } from '../../application/commands/create-project/create-project.command';
|
||||
import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command';
|
||||
import { GetProjectQuery } from '../../application/queries/get-project/get-project.query';
|
||||
import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query';
|
||||
import { type CreateProjectDto } from '../dto/create-project.dto';
|
||||
import { type SearchProjectsDto } from '../dto/search-projects.dto';
|
||||
import { type UpdateProjectDto } from '../dto/update-project.dto';
|
||||
|
||||
@ApiTags('projects')
|
||||
@Controller('projects')
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
// ── Public endpoints ──────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Danh sách dự án', description: 'Tìm kiếm và lọc dự án bất động sản' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
||||
@Get()
|
||||
async listProjects(@Query() dto: SearchProjectsDto) {
|
||||
return this.queryBus.execute(
|
||||
new ListProjectsQuery(
|
||||
dto.q,
|
||||
dto.status,
|
||||
dto.city,
|
||||
dto.district,
|
||||
dto.developer,
|
||||
dto.isVerified,
|
||||
dto.page ?? 1,
|
||||
dto.limit ?? 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Chi tiết dự án', description: 'Xem chi tiết dự án theo slug hoặc ID' })
|
||||
@ApiResponse({ status: 200, description: 'Thông tin chi tiết dự án' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
|
||||
@Get(':slugOrId')
|
||||
async getProject(@Param('slugOrId') slugOrId: string) {
|
||||
const result = await this.queryBus.execute(new GetProjectQuery(slugOrId));
|
||||
if (!result) {
|
||||
throw new NotFoundException('Dự án', slugOrId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Admin endpoints ───────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Tạo dự án (admin)', description: 'Tạo mới dự án bất động sản' })
|
||||
@ApiResponse({ status: 201, description: 'Dự án đã tạo' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Post()
|
||||
async createProject(@Body() dto: CreateProjectDto) {
|
||||
return this.commandBus.execute(
|
||||
new CreateProjectCommand(
|
||||
dto.name,
|
||||
dto.slug,
|
||||
dto.developer,
|
||||
dto.developerLogo ?? null,
|
||||
dto.totalUnits,
|
||||
dto.status,
|
||||
dto.latitude,
|
||||
dto.longitude,
|
||||
dto.address,
|
||||
dto.ward,
|
||||
dto.district,
|
||||
dto.city,
|
||||
dto.description ?? null,
|
||||
dto.amenities ?? null,
|
||||
dto.masterPlanUrl ?? null,
|
||||
dto.minPrice ? BigInt(dto.minPrice) : null,
|
||||
dto.maxPrice ? BigInt(dto.maxPrice) : null,
|
||||
dto.pricePerM2Range ?? null,
|
||||
dto.totalArea ?? null,
|
||||
dto.buildingCount ?? null,
|
||||
dto.floorCount ?? null,
|
||||
dto.unitTypes ?? null,
|
||||
dto.tags ?? [],
|
||||
dto.startDate ? new Date(dto.startDate) : null,
|
||||
dto.completionDate ? new Date(dto.completionDate) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Cập nhật dự án (admin)', description: 'Cập nhật thông tin dự án' })
|
||||
@ApiResponse({ status: 200, description: 'Dự án đã cập nhật' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Patch(':id')
|
||||
async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
||||
return this.commandBus.execute(
|
||||
new UpdateProjectCommand(
|
||||
id,
|
||||
dto.name,
|
||||
dto.developer,
|
||||
dto.developerLogo,
|
||||
dto.totalUnits,
|
||||
dto.completedUnits,
|
||||
dto.status,
|
||||
dto.description,
|
||||
dto.amenities,
|
||||
dto.masterPlanUrl,
|
||||
dto.minPrice !== undefined ? (dto.minPrice ? BigInt(dto.minPrice) : null) : undefined,
|
||||
dto.maxPrice !== undefined ? (dto.maxPrice ? BigInt(dto.maxPrice) : null) : undefined,
|
||||
dto.pricePerM2Range,
|
||||
dto.totalArea,
|
||||
dto.buildingCount,
|
||||
dto.floorCount,
|
||||
dto.unitTypes,
|
||||
dto.media,
|
||||
dto.documents,
|
||||
dto.tags,
|
||||
dto.isVerified,
|
||||
dto.startDate !== undefined ? (dto.startDate ? new Date(dto.startDate) : null) : undefined,
|
||||
dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@ApiProperty({ example: 'Vinhomes Grand Park', description: 'Tên dự án' })
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'vinhomes-grand-park', description: 'URL slug (unique)' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: 'Vingroup' })
|
||||
@IsString()
|
||||
developer!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
developerLogo?: string;
|
||||
|
||||
@ApiProperty({ example: 10000, description: 'Tổng số căn hộ/đơn vị' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
totalUnits!: number;
|
||||
|
||||
@ApiProperty({ enum: ProjectDevelopmentStatus, example: 'UNDER_CONSTRUCTION' })
|
||||
@IsEnum(ProjectDevelopmentStatus)
|
||||
status!: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiProperty({ example: 10.8231, description: 'Latitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.8368, description: 'Longitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ example: 'Phường Long Thạnh Mỹ, TP. Thủ Đức' })
|
||||
@IsString()
|
||||
address!: string;
|
||||
|
||||
@ApiProperty({ example: 'Long Thạnh Mỹ' })
|
||||
@IsString()
|
||||
ward!: string;
|
||||
|
||||
@ApiProperty({ example: 'Thủ Đức' })
|
||||
@IsString()
|
||||
district!: string;
|
||||
|
||||
@ApiProperty({ example: 'Hồ Chí Minh' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mô tả dự án' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Tiện ích dự án (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
amenities?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/masterplan.jpg' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
masterPlanUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '3000000000', description: 'Giá thấp nhất (VND)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '15000000000', description: 'Giá cao nhất (VND)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Giá/m² range (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
pricePerM2Range?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: 271, description: 'Tổng diện tích (ha)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
totalArea?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 14 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
buildingCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 35 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
floorCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Loại căn hộ (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
unitTypes?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: ['cao-cap', 'can-ho'], description: 'Tags' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: '2020-06-01', description: 'Ngày khởi công' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2025-12-31', description: 'Ngày dự kiến hoàn thành' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
completionDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsEnum, IsOptional, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
||||
|
||||
export class SearchProjectsDto {
|
||||
@ApiPropertyOptional({ description: 'Tìm kiếm theo tên, chủ đầu tư, quận, thành phố' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectDevelopmentStatus)
|
||||
status?: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Hồ Chí Minh' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Thủ Đức' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Vingroup' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
developer?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
isVerified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
Min,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() developer?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() developerLogo?: string | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(1)
|
||||
totalUnits?: number;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||
completedUnits?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||
@IsOptional() @IsEnum(ProjectDevelopmentStatus)
|
||||
status?: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() amenities?: Record<string, unknown> | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() masterPlanUrl?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() minPrice?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() maxPrice?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() pricePerM2Range?: Record<string, unknown> | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||
totalArea?: number | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) buildingCount?: number | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) floorCount?: number | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() unitTypes?: Record<string, unknown> | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() media?: Record<string, unknown>[] | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() documents?: Record<string, unknown>[] | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user