feat(industrial): add IndustrialListing CRUD endpoints + Typesense indexing

Wire full DDD stack for IndustrialListing: domain entity, repository interface,
CQRS commands/queries with handlers, Prisma repository, Typesense sync on
create/update/delete, controller with 5 REST endpoints, and validated DTOs.
Register all providers in IndustrialModule.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:08:08 +07:00
parent 13bd76ac5d
commit 8f2d325d60
19 changed files with 1605 additions and 2 deletions

View File

@@ -0,0 +1,165 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class CreateIndustrialListingDto {
@ApiProperty({ description: 'ID khu công nghiệp' })
@IsString()
parkId!: string;
@ApiPropertyOptional({ description: 'ID môi giới' })
@IsOptional()
@IsString()
agentId?: string;
@ApiProperty({ enum: IndustrialPropertyType, example: 'READY_BUILT_FACTORY' })
@IsEnum(IndustrialPropertyType)
propertyType!: IndustrialPropertyType;
@ApiProperty({ enum: IndustrialLeaseType, example: 'FACTORY_LEASE' })
@IsEnum(IndustrialLeaseType)
leaseType!: IndustrialLeaseType;
@ApiProperty({ example: 'Nhà xưởng 5000m² tại KCN VSIP', description: 'Tiêu đề' })
@IsString()
@MaxLength(300)
title!: string;
@ApiPropertyOptional({ description: 'Mô tả chi tiết' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 5000, description: 'Diện tích (m²)' })
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2!: number;
@ApiPropertyOptional({ example: 12, description: 'Chiều cao trần (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional({ example: 3, description: 'Tải trọng sàn (tấn/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional({ example: 12, description: 'Khoảng cách cột (m)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional({ example: 4, description: 'Số dock' })
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional({ example: 10, description: 'Tải trọng cẩu trục (tấn)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional({ example: 200, description: 'Diện tích văn phòng (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional({ example: 5.5, description: 'Giá thuê (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional({ example: 'usd/m2/month', description: 'Đơn vị giá' })
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional({ example: 27500, description: 'Tổng giá thuê' })
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional({ example: 0.6, description: 'Phí quản lý' })
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional({ example: 3, description: 'Số tháng đặt cọc' })
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional({ example: 3, description: 'Thời hạn thuê tối thiểu (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional({ example: 50, description: 'Thời hạn thuê tối đa (năm)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional({ description: 'Ngày hết hạn thuê' })
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional({ description: 'Ngày có thể bắt đầu thuê' })
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional({ example: 100, description: 'Cấp nước (m³/ngày)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional({ description: 'Hình ảnh / tài liệu' })
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}

View File

@@ -0,0 +1,71 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class SearchIndustrialListingsDto {
@ApiPropertyOptional({ description: 'Lọc theo KCN' })
@IsOptional()
@IsString()
parkId?: string;
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional({ example: 1000, description: 'Diện tích tối thiểu (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
minAreaM2?: number;
@ApiPropertyOptional({ example: 10000, description: 'Diện tích tối đa (m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxAreaM2?: number;
@ApiPropertyOptional({ example: 3, description: 'Giá tối thiểu (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minPriceUsdM2?: number;
@ApiPropertyOptional({ example: 10, description: 'Giá tối đa (USD/m²)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxPriceUsdM2?: number;
@ApiPropertyOptional({ example: 'nhà xưởng', description: 'Từ khóa tìm kiếm' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,165 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IndustrialLeaseType, IndustrialListingStatus, IndustrialPropertyType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsBoolean,
IsArray,
IsObject,
IsDateString,
Min,
MaxLength,
} from 'class-validator';
export class UpdateIndustrialListingDto {
@ApiPropertyOptional({ enum: IndustrialPropertyType })
@IsOptional()
@IsEnum(IndustrialPropertyType)
propertyType?: IndustrialPropertyType;
@ApiPropertyOptional({ enum: IndustrialLeaseType })
@IsOptional()
@IsEnum(IndustrialLeaseType)
leaseType?: IndustrialLeaseType;
@ApiPropertyOptional({ enum: IndustrialListingStatus })
@IsOptional()
@IsEnum(IndustrialListingStatus)
status?: IndustrialListingStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(300)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
areaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
ceilingHeightM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
floorLoadTonM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
columnSpacingM?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
dockCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
craneCapacityTon?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasMezzanine?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasOfficeArea?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
officeAreaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
priceUsdM2?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
pricingUnit?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
totalLeasePrice?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
managementFee?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
minLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
maxLeaseYears?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
leaseExpiry?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
availableFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
powerCapacityKva?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
waterSupplyM3Day?: number;
@ApiPropertyOptional()
@IsOptional()
@IsArray()
@IsObject({ each: true })
media?: Record<string, unknown>[];
}