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,34 @@
|
||||
import type { IndustrialParkStatus, VietnamRegion } from '@prisma/client';
|
||||
|
||||
export class CreateIndustrialParkCommand {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly nameEn: string | null,
|
||||
public readonly slug: string,
|
||||
public readonly developer: string,
|
||||
public readonly operator: string | null,
|
||||
public readonly status: IndustrialParkStatus,
|
||||
public readonly latitude: number,
|
||||
public readonly longitude: number,
|
||||
public readonly address: string,
|
||||
public readonly district: string,
|
||||
public readonly province: string,
|
||||
public readonly region: VietnamRegion,
|
||||
public readonly totalAreaHa: number,
|
||||
public readonly leasableAreaHa: number,
|
||||
public readonly occupancyRate: number,
|
||||
public readonly remainingAreaHa: number,
|
||||
public readonly tenantCount: number,
|
||||
public readonly establishedYear: number | null,
|
||||
public readonly landRentUsdM2Year: number | null,
|
||||
public readonly rbfRentUsdM2Month: number | null,
|
||||
public readonly rbwRentUsdM2Month: number | null,
|
||||
public readonly managementFeeUsd: number | null,
|
||||
public readonly infrastructure: Record<string, unknown> | null,
|
||||
public readonly connectivity: Record<string, unknown> | null,
|
||||
public readonly incentives: Record<string, unknown> | null,
|
||||
public readonly targetIndustries: string[],
|
||||
public readonly description: string | null,
|
||||
public readonly descriptionEn: string | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException } from '@modules/shared';
|
||||
import { IndustrialParkEntity } from '../../../domain/entities/industrial-park.entity';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { CreateIndustrialParkCommand } from './create-industrial-park.command';
|
||||
|
||||
@CommandHandler(CreateIndustrialParkCommand)
|
||||
export class CreateIndustrialParkHandler implements ICommandHandler<CreateIndustrialParkCommand> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateIndustrialParkCommand): Promise<{ id: string }> {
|
||||
const existing = await this.repo.findBySlug(cmd.slug);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Industrial park with slug "${cmd.slug}" already exists`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const entity = new IndustrialParkEntity(
|
||||
createId(),
|
||||
{
|
||||
name: cmd.name,
|
||||
nameEn: cmd.nameEn,
|
||||
slug: cmd.slug,
|
||||
developer: cmd.developer,
|
||||
operator: cmd.operator,
|
||||
status: cmd.status,
|
||||
latitude: cmd.latitude,
|
||||
longitude: cmd.longitude,
|
||||
address: cmd.address,
|
||||
district: cmd.district,
|
||||
province: cmd.province,
|
||||
region: cmd.region,
|
||||
totalAreaHa: cmd.totalAreaHa,
|
||||
leasableAreaHa: cmd.leasableAreaHa,
|
||||
occupancyRate: cmd.occupancyRate,
|
||||
remainingAreaHa: cmd.remainingAreaHa,
|
||||
tenantCount: cmd.tenantCount,
|
||||
establishedYear: cmd.establishedYear,
|
||||
landRentUsdM2Year: cmd.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: cmd.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: cmd.rbwRentUsdM2Month,
|
||||
managementFeeUsd: cmd.managementFeeUsd,
|
||||
infrastructure: cmd.infrastructure,
|
||||
connectivity: cmd.connectivity,
|
||||
incentives: cmd.incentives,
|
||||
targetIndustries: cmd.targetIndustries,
|
||||
existingTenants: null,
|
||||
certifications: null,
|
||||
media: null,
|
||||
documents: null,
|
||||
description: cmd.description,
|
||||
descriptionEn: cmd.descriptionEn,
|
||||
isVerified: false,
|
||||
},
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
await this.repo.save(entity);
|
||||
return { id: entity.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IndustrialParkStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateIndustrialParkCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly name?: string,
|
||||
public readonly nameEn?: string | null,
|
||||
public readonly developer?: string,
|
||||
public readonly operator?: string | null,
|
||||
public readonly status?: IndustrialParkStatus,
|
||||
public readonly occupancyRate?: number,
|
||||
public readonly remainingAreaHa?: number,
|
||||
public readonly tenantCount?: number,
|
||||
public readonly landRentUsdM2Year?: number | null,
|
||||
public readonly rbfRentUsdM2Month?: number | null,
|
||||
public readonly rbwRentUsdM2Month?: number | null,
|
||||
public readonly managementFeeUsd?: number | null,
|
||||
public readonly infrastructure?: Record<string, unknown> | null,
|
||||
public readonly connectivity?: Record<string, unknown> | null,
|
||||
public readonly incentives?: Record<string, unknown> | null,
|
||||
public readonly targetIndustries?: string[],
|
||||
public readonly description?: string | null,
|
||||
public readonly descriptionEn?: string | null,
|
||||
public readonly isVerified?: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { UpdateIndustrialParkCommand } from './update-industrial-park.command';
|
||||
|
||||
@CommandHandler(UpdateIndustrialParkCommand)
|
||||
export class UpdateIndustrialParkHandler implements ICommandHandler<UpdateIndustrialParkCommand> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: UpdateIndustrialParkCommand): Promise<void> {
|
||||
const entity = await this.repo.findById(cmd.id);
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Industrial park', cmd.id);
|
||||
}
|
||||
|
||||
entity.updateDetails({
|
||||
name: cmd.name,
|
||||
nameEn: cmd.nameEn,
|
||||
developer: cmd.developer,
|
||||
operator: cmd.operator,
|
||||
status: cmd.status,
|
||||
occupancyRate: cmd.occupancyRate,
|
||||
remainingAreaHa: cmd.remainingAreaHa,
|
||||
tenantCount: cmd.tenantCount,
|
||||
landRentUsdM2Year: cmd.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: cmd.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: cmd.rbwRentUsdM2Month,
|
||||
managementFeeUsd: cmd.managementFeeUsd,
|
||||
infrastructure: cmd.infrastructure,
|
||||
connectivity: cmd.connectivity,
|
||||
incentives: cmd.incentives,
|
||||
targetIndustries: cmd.targetIndustries,
|
||||
description: cmd.description,
|
||||
descriptionEn: cmd.descriptionEn,
|
||||
isVerified: cmd.isVerified,
|
||||
});
|
||||
|
||||
await this.repo.update(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ValidationException } from '@modules/shared';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
type IndustrialParkDetailData,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { CompareIndustrialParksQuery } from './compare-industrial-parks.query';
|
||||
|
||||
@QueryHandler(CompareIndustrialParksQuery)
|
||||
export class CompareIndustrialParksHandler implements IQueryHandler<CompareIndustrialParksQuery> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: CompareIndustrialParksQuery): Promise<IndustrialParkDetailData[]> {
|
||||
if (query.ids.length < 2 || query.ids.length > 5) {
|
||||
throw new ValidationException('Compare requires 2-5 park IDs');
|
||||
}
|
||||
return this.repo.compareParks(query.ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class CompareIndustrialParksQuery {
|
||||
constructor(
|
||||
public readonly ids: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
type IndustrialParkDetailData,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { GetIndustrialParkQuery } from './get-industrial-park.query';
|
||||
|
||||
@QueryHandler(GetIndustrialParkQuery)
|
||||
export class GetIndustrialParkHandler implements IQueryHandler<GetIndustrialParkQuery> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIndustrialParkQuery): Promise<IndustrialParkDetailData | null> {
|
||||
// Try slug first, then ID
|
||||
const result = await this.repo.findDetailBySlug(query.slugOrId);
|
||||
if (result) return result;
|
||||
return this.repo.findDetailById(query.slugOrId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetIndustrialParkQuery {
|
||||
constructor(
|
||||
public readonly slugOrId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
type IndustrialMarketData,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { IndustrialMarketQuery } from './industrial-market.query';
|
||||
|
||||
@QueryHandler(IndustrialMarketQuery)
|
||||
export class IndustrialMarketHandler implements IQueryHandler<IndustrialMarketQuery> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(_query: IndustrialMarketQuery): Promise<IndustrialMarketData> {
|
||||
return this.repo.getMarketData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class IndustrialMarketQuery {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
type IndustrialParkStatsData,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { IndustrialParkStatsQuery } from './industrial-park-stats.query';
|
||||
|
||||
@QueryHandler(IndustrialParkStatsQuery)
|
||||
export class IndustrialParkStatsHandler implements IQueryHandler<IndustrialParkStatsQuery> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(_query: IndustrialParkStatsQuery): Promise<IndustrialParkStatsData> {
|
||||
return this.repo.getStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class IndustrialParkStatsQuery {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
INDUSTRIAL_PARK_REPOSITORY,
|
||||
type IIndustrialParkRepository,
|
||||
type IndustrialParkListItem,
|
||||
type PaginatedResult,
|
||||
} from '../../../domain/repositories/industrial-park.repository';
|
||||
import { ListIndustrialParksQuery } from './list-industrial-parks.query';
|
||||
|
||||
@QueryHandler(ListIndustrialParksQuery)
|
||||
export class ListIndustrialParksHandler implements IQueryHandler<ListIndustrialParksQuery> {
|
||||
constructor(
|
||||
@Inject(INDUSTRIAL_PARK_REPOSITORY)
|
||||
private readonly repo: IIndustrialParkRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListIndustrialParksQuery): Promise<PaginatedResult<IndustrialParkListItem>> {
|
||||
return this.repo.search({
|
||||
query: query.query,
|
||||
province: query.province,
|
||||
region: query.region,
|
||||
status: query.status,
|
||||
minAreaHa: query.minAreaHa,
|
||||
maxRentUsdM2: query.maxRentUsdM2,
|
||||
targetIndustry: query.targetIndustry,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IndustrialParkStatus, VietnamRegion } from '@prisma/client';
|
||||
|
||||
export class ListIndustrialParksQuery {
|
||||
constructor(
|
||||
public readonly query?: string,
|
||||
public readonly province?: string,
|
||||
public readonly region?: VietnamRegion,
|
||||
public readonly status?: IndustrialParkStatus,
|
||||
public readonly minAreaHa?: number,
|
||||
public readonly maxRentUsdM2?: number,
|
||||
public readonly targetIndustry?: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { type IndustrialParkStatus, type VietnamRegion } from '@prisma/client';
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
|
||||
export interface IndustrialParkProps {
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
operator: string | null;
|
||||
status: IndustrialParkStatus;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
district: string;
|
||||
province: string;
|
||||
region: VietnamRegion;
|
||||
totalAreaHa: number;
|
||||
leasableAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
infrastructure: Record<string, unknown> | null;
|
||||
connectivity: Record<string, unknown> | null;
|
||||
incentives: Record<string, unknown> | null;
|
||||
targetIndustries: string[];
|
||||
existingTenants: Record<string, unknown>[] | null;
|
||||
certifications: string[] | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
documents: Record<string, unknown>[] | null;
|
||||
description: string | null;
|
||||
descriptionEn: string | null;
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
export class IndustrialParkEntity extends AggregateRoot<string> {
|
||||
private _name: string;
|
||||
private _nameEn: string | null;
|
||||
private _slug: string;
|
||||
private _developer: string;
|
||||
private _operator: string | null;
|
||||
private _status: IndustrialParkStatus;
|
||||
private _latitude: number;
|
||||
private _longitude: number;
|
||||
private _address: string;
|
||||
private _district: string;
|
||||
private _province: string;
|
||||
private _region: VietnamRegion;
|
||||
private _totalAreaHa: number;
|
||||
private _leasableAreaHa: number;
|
||||
private _occupancyRate: number;
|
||||
private _remainingAreaHa: number;
|
||||
private _tenantCount: number;
|
||||
private _establishedYear: number | null;
|
||||
private _landRentUsdM2Year: number | null;
|
||||
private _rbfRentUsdM2Month: number | null;
|
||||
private _rbwRentUsdM2Month: number | null;
|
||||
private _managementFeeUsd: number | null;
|
||||
private _infrastructure: Record<string, unknown> | null;
|
||||
private _connectivity: Record<string, unknown> | null;
|
||||
private _incentives: Record<string, unknown> | null;
|
||||
private _targetIndustries: string[];
|
||||
private _existingTenants: Record<string, unknown>[] | null;
|
||||
private _certifications: string[] | null;
|
||||
private _media: Record<string, unknown>[] | null;
|
||||
private _documents: Record<string, unknown>[] | null;
|
||||
private _description: string | null;
|
||||
private _descriptionEn: string | null;
|
||||
private _isVerified: boolean;
|
||||
|
||||
constructor(id: string, props: IndustrialParkProps, createdAt: Date, updatedAt: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._name = props.name;
|
||||
this._nameEn = props.nameEn;
|
||||
this._slug = props.slug;
|
||||
this._developer = props.developer;
|
||||
this._operator = props.operator;
|
||||
this._status = props.status;
|
||||
this._latitude = props.latitude;
|
||||
this._longitude = props.longitude;
|
||||
this._address = props.address;
|
||||
this._district = props.district;
|
||||
this._province = props.province;
|
||||
this._region = props.region;
|
||||
this._totalAreaHa = props.totalAreaHa;
|
||||
this._leasableAreaHa = props.leasableAreaHa;
|
||||
this._occupancyRate = props.occupancyRate;
|
||||
this._remainingAreaHa = props.remainingAreaHa;
|
||||
this._tenantCount = props.tenantCount;
|
||||
this._establishedYear = props.establishedYear;
|
||||
this._landRentUsdM2Year = props.landRentUsdM2Year;
|
||||
this._rbfRentUsdM2Month = props.rbfRentUsdM2Month;
|
||||
this._rbwRentUsdM2Month = props.rbwRentUsdM2Month;
|
||||
this._managementFeeUsd = props.managementFeeUsd;
|
||||
this._infrastructure = props.infrastructure;
|
||||
this._connectivity = props.connectivity;
|
||||
this._incentives = props.incentives;
|
||||
this._targetIndustries = props.targetIndustries;
|
||||
this._existingTenants = props.existingTenants;
|
||||
this._certifications = props.certifications;
|
||||
this._media = props.media;
|
||||
this._documents = props.documents;
|
||||
this._description = props.description;
|
||||
this._descriptionEn = props.descriptionEn;
|
||||
this._isVerified = props.isVerified;
|
||||
}
|
||||
|
||||
get name() { return this._name; }
|
||||
get nameEn() { return this._nameEn; }
|
||||
get slug() { return this._slug; }
|
||||
get developer() { return this._developer; }
|
||||
get operator() { return this._operator; }
|
||||
get status() { return this._status; }
|
||||
get latitude() { return this._latitude; }
|
||||
get longitude() { return this._longitude; }
|
||||
get address() { return this._address; }
|
||||
get district() { return this._district; }
|
||||
get province() { return this._province; }
|
||||
get region() { return this._region; }
|
||||
get totalAreaHa() { return this._totalAreaHa; }
|
||||
get leasableAreaHa() { return this._leasableAreaHa; }
|
||||
get occupancyRate() { return this._occupancyRate; }
|
||||
get remainingAreaHa() { return this._remainingAreaHa; }
|
||||
get tenantCount() { return this._tenantCount; }
|
||||
get establishedYear() { return this._establishedYear; }
|
||||
get landRentUsdM2Year() { return this._landRentUsdM2Year; }
|
||||
get rbfRentUsdM2Month() { return this._rbfRentUsdM2Month; }
|
||||
get rbwRentUsdM2Month() { return this._rbwRentUsdM2Month; }
|
||||
get managementFeeUsd() { return this._managementFeeUsd; }
|
||||
get infrastructure() { return this._infrastructure; }
|
||||
get connectivity() { return this._connectivity; }
|
||||
get incentives() { return this._incentives; }
|
||||
get targetIndustries() { return this._targetIndustries; }
|
||||
get existingTenants() { return this._existingTenants; }
|
||||
get certifications() { return this._certifications; }
|
||||
get media() { return this._media; }
|
||||
get documents() { return this._documents; }
|
||||
get description() { return this._description; }
|
||||
get descriptionEn() { return this._descriptionEn; }
|
||||
get isVerified() { return this._isVerified; }
|
||||
|
||||
updateDetails(props: Partial<IndustrialParkProps>): void {
|
||||
if (props.name !== undefined) this._name = props.name;
|
||||
if (props.nameEn !== undefined) this._nameEn = props.nameEn;
|
||||
if (props.developer !== undefined) this._developer = props.developer;
|
||||
if (props.operator !== undefined) this._operator = props.operator;
|
||||
if (props.status !== undefined) this._status = props.status;
|
||||
if (props.occupancyRate !== undefined) this._occupancyRate = props.occupancyRate;
|
||||
if (props.remainingAreaHa !== undefined) this._remainingAreaHa = props.remainingAreaHa;
|
||||
if (props.tenantCount !== undefined) this._tenantCount = props.tenantCount;
|
||||
if (props.landRentUsdM2Year !== undefined) this._landRentUsdM2Year = props.landRentUsdM2Year;
|
||||
if (props.rbfRentUsdM2Month !== undefined) this._rbfRentUsdM2Month = props.rbfRentUsdM2Month;
|
||||
if (props.rbwRentUsdM2Month !== undefined) this._rbwRentUsdM2Month = props.rbwRentUsdM2Month;
|
||||
if (props.managementFeeUsd !== undefined) this._managementFeeUsd = props.managementFeeUsd;
|
||||
if (props.infrastructure !== undefined) this._infrastructure = props.infrastructure;
|
||||
if (props.connectivity !== undefined) this._connectivity = props.connectivity;
|
||||
if (props.incentives !== undefined) this._incentives = props.incentives;
|
||||
if (props.targetIndustries !== undefined) this._targetIndustries = props.targetIndustries;
|
||||
if (props.existingTenants !== undefined) this._existingTenants = props.existingTenants;
|
||||
if (props.certifications !== undefined) this._certifications = props.certifications;
|
||||
if (props.media !== undefined) this._media = props.media;
|
||||
if (props.documents !== undefined) this._documents = props.documents;
|
||||
if (props.description !== undefined) this._description = props.description;
|
||||
if (props.descriptionEn !== undefined) this._descriptionEn = props.descriptionEn;
|
||||
if (props.isVerified !== undefined) this._isVerified = props.isVerified;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IndustrialParkStatus, VietnamRegion } from '@prisma/client';
|
||||
import type { IndustrialParkEntity } from '../entities/industrial-park.entity';
|
||||
|
||||
export const INDUSTRIAL_PARK_REPOSITORY = Symbol('INDUSTRIAL_PARK_REPOSITORY');
|
||||
|
||||
export interface IndustrialParkSearchParams {
|
||||
query?: string;
|
||||
province?: string;
|
||||
region?: VietnamRegion;
|
||||
status?: IndustrialParkStatus;
|
||||
minAreaHa?: number;
|
||||
maxRentUsdM2?: number;
|
||||
targetIndustry?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface IndustrialParkListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
status: IndustrialParkStatus;
|
||||
province: string;
|
||||
region: VietnamRegion;
|
||||
totalAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
targetIndustries: string[];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface IndustrialParkDetailData {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
operator: string | null;
|
||||
status: IndustrialParkStatus;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
district: string;
|
||||
province: string;
|
||||
region: VietnamRegion;
|
||||
totalAreaHa: number;
|
||||
leasableAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
infrastructure: Record<string, unknown> | null;
|
||||
connectivity: Record<string, unknown> | null;
|
||||
incentives: Record<string, unknown> | null;
|
||||
targetIndustries: string[];
|
||||
existingTenants: Record<string, unknown>[] | null;
|
||||
certifications: string[] | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
documents: Record<string, unknown>[] | null;
|
||||
description: string | null;
|
||||
descriptionEn: string | null;
|
||||
isVerified: boolean;
|
||||
listingCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IndustrialParkStatsData {
|
||||
totalParks: number;
|
||||
totalAreaHa: number;
|
||||
avgOccupancyRate: number;
|
||||
totalTenants: number;
|
||||
byRegion: { region: string; count: number; avgOccupancy: number }[];
|
||||
byStatus: { status: string; count: number }[];
|
||||
topProvinces: { province: string; count: number; avgRent: number | null }[];
|
||||
}
|
||||
|
||||
export interface IndustrialMarketData {
|
||||
totalParks: number;
|
||||
avgOccupancyRate: number;
|
||||
avgLandRentUsdM2: number | null;
|
||||
avgRbfRentUsdM2: number | null;
|
||||
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
}
|
||||
|
||||
export interface IIndustrialParkRepository {
|
||||
findById(id: string): Promise<IndustrialParkEntity | null>;
|
||||
findBySlug(slug: string): Promise<IndustrialParkEntity | null>;
|
||||
findDetailBySlug(slug: string): Promise<IndustrialParkDetailData | null>;
|
||||
findDetailById(id: string): Promise<IndustrialParkDetailData | null>;
|
||||
save(entity: IndustrialParkEntity): Promise<void>;
|
||||
update(entity: IndustrialParkEntity): Promise<void>;
|
||||
search(params: IndustrialParkSearchParams): Promise<PaginatedResult<IndustrialParkListItem>>;
|
||||
compareParks(ids: string[]): Promise<IndustrialParkDetailData[]>;
|
||||
getStats(): Promise<IndustrialParkStatsData>;
|
||||
getMarketData(): Promise<IndustrialMarketData>;
|
||||
}
|
||||
9
apps/api/src/modules/industrial/index.ts
Normal file
9
apps/api/src/modules/industrial/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { IndustrialModule } from './industrial.module';
|
||||
export { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
|
||||
export type {
|
||||
IIndustrialParkRepository,
|
||||
IndustrialParkDetailData,
|
||||
IndustrialParkListItem,
|
||||
IndustrialParkStatsData,
|
||||
IndustrialMarketData,
|
||||
} from './domain/repositories/industrial-park.repository';
|
||||
40
apps/api/src/modules/industrial/industrial.module.ts
Normal file
40
apps/api/src/modules/industrial/industrial.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
|
||||
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
|
||||
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
|
||||
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
|
||||
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
|
||||
import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler';
|
||||
import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler';
|
||||
import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository';
|
||||
import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository';
|
||||
import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service';
|
||||
import { IndustrialParksController } from './presentation/controllers/industrial-parks.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
CreateIndustrialParkHandler,
|
||||
UpdateIndustrialParkHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetIndustrialParkHandler,
|
||||
ListIndustrialParksHandler,
|
||||
CompareIndustrialParksHandler,
|
||||
IndustrialParkStatsHandler,
|
||||
IndustrialMarketHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, SearchModule],
|
||||
controllers: [IndustrialParksController],
|
||||
providers: [
|
||||
{ provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository },
|
||||
TypesenseIndustrialService,
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [INDUSTRIAL_PARK_REPOSITORY, TypesenseIndustrialService],
|
||||
})
|
||||
export class IndustrialModule {}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { IndustrialParkEntity } from '../../domain/entities/industrial-park.entity';
|
||||
import type {
|
||||
IIndustrialParkRepository,
|
||||
IndustrialParkSearchParams,
|
||||
PaginatedResult,
|
||||
IndustrialParkListItem,
|
||||
IndustrialParkDetailData,
|
||||
IndustrialParkStatsData,
|
||||
IndustrialMarketData,
|
||||
} from '../../domain/repositories/industrial-park.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaIndustrialParkRepository implements IIndustrialParkRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<IndustrialParkEntity | null> {
|
||||
const row = await this.prisma.$queryRaw<RawPark[]>`
|
||||
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||
FROM "IndustrialPark" WHERE id = ${id} LIMIT 1
|
||||
`;
|
||||
return row[0] ? this.toDomain(row[0]) : null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<IndustrialParkEntity | null> {
|
||||
const row = await this.prisma.$queryRaw<RawPark[]>`
|
||||
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||
FROM "IndustrialPark" WHERE slug = ${slug} LIMIT 1
|
||||
`;
|
||||
return row[0] ? this.toDomain(row[0]) : null;
|
||||
}
|
||||
|
||||
async findDetailBySlug(slug: string): Promise<IndustrialParkDetailData | null> {
|
||||
const rows = await this.prisma.$queryRaw<RawParkDetail[]>`
|
||||
SELECT p.*,
|
||||
ST_Y(p.location::geometry) as lat,
|
||||
ST_X(p.location::geometry) as lng,
|
||||
COUNT(l.id)::int as "listingCount"
|
||||
FROM "IndustrialPark" p
|
||||
LEFT JOIN "IndustrialListing" l ON l."parkId" = p.id AND l.status = 'ACTIVE'
|
||||
WHERE p.slug = ${slug}
|
||||
GROUP BY p.id
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||
}
|
||||
|
||||
async findDetailById(id: string): Promise<IndustrialParkDetailData | null> {
|
||||
const rows = await this.prisma.$queryRaw<RawParkDetail[]>`
|
||||
SELECT p.*,
|
||||
ST_Y(p.location::geometry) as lat,
|
||||
ST_X(p.location::geometry) as lng,
|
||||
COUNT(l.id)::int as "listingCount"
|
||||
FROM "IndustrialPark" p
|
||||
LEFT JOIN "IndustrialListing" l ON l."parkId" = p.id AND l.status = 'ACTIVE'
|
||||
WHERE p.id = ${id}
|
||||
GROUP BY p.id
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||
}
|
||||
|
||||
async save(entity: IndustrialParkEntity): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "IndustrialPark" (
|
||||
id, name, "nameEn", slug, developer, operator, status, location,
|
||||
address, district, province, region, "totalAreaHa", "leasableAreaHa",
|
||||
"occupancyRate", "remainingAreaHa", "tenantCount", "establishedYear",
|
||||
"landRentUsdM2Year", "rbfRentUsdM2Month", "rbwRentUsdM2Month",
|
||||
"managementFeeUsd", infrastructure, connectivity, incentives,
|
||||
"targetIndustries", "existingTenants", certifications, media, documents,
|
||||
description, "descriptionEn", "isVerified", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
${entity.id}, ${entity.name}, ${entity.nameEn}, ${entity.slug},
|
||||
${entity.developer}, ${entity.operator}, ${entity.status}::"IndustrialParkStatus",
|
||||
ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326),
|
||||
${entity.address}, ${entity.district}, ${entity.province}, ${entity.region}::"VietnamRegion",
|
||||
${entity.totalAreaHa}, ${entity.leasableAreaHa}, ${entity.occupancyRate},
|
||||
${entity.remainingAreaHa}, ${entity.tenantCount}, ${entity.establishedYear},
|
||||
${entity.landRentUsdM2Year}, ${entity.rbfRentUsdM2Month}, ${entity.rbwRentUsdM2Month},
|
||||
${entity.managementFeeUsd},
|
||||
${entity.infrastructure ? JSON.stringify(entity.infrastructure) : null}::jsonb,
|
||||
${entity.connectivity ? JSON.stringify(entity.connectivity) : null}::jsonb,
|
||||
${entity.incentives ? JSON.stringify(entity.incentives) : null}::jsonb,
|
||||
${entity.targetIndustries}::text[],
|
||||
${entity.existingTenants ? JSON.stringify(entity.existingTenants) : null}::jsonb,
|
||||
${entity.certifications ? JSON.stringify(entity.certifications) : null}::jsonb,
|
||||
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
|
||||
${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
|
||||
${entity.description}, ${entity.descriptionEn},
|
||||
${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
async update(entity: IndustrialParkEntity): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "IndustrialPark" SET
|
||||
name = ${entity.name}, "nameEn" = ${entity.nameEn},
|
||||
developer = ${entity.developer}, operator = ${entity.operator},
|
||||
status = ${entity.status}::"IndustrialParkStatus",
|
||||
"occupancyRate" = ${entity.occupancyRate},
|
||||
"remainingAreaHa" = ${entity.remainingAreaHa},
|
||||
"tenantCount" = ${entity.tenantCount},
|
||||
"landRentUsdM2Year" = ${entity.landRentUsdM2Year},
|
||||
"rbfRentUsdM2Month" = ${entity.rbfRentUsdM2Month},
|
||||
"rbwRentUsdM2Month" = ${entity.rbwRentUsdM2Month},
|
||||
"managementFeeUsd" = ${entity.managementFeeUsd},
|
||||
infrastructure = ${entity.infrastructure ? JSON.stringify(entity.infrastructure) : null}::jsonb,
|
||||
connectivity = ${entity.connectivity ? JSON.stringify(entity.connectivity) : null}::jsonb,
|
||||
incentives = ${entity.incentives ? JSON.stringify(entity.incentives) : null}::jsonb,
|
||||
"targetIndustries" = ${entity.targetIndustries}::text[],
|
||||
description = ${entity.description}, "descriptionEn" = ${entity.descriptionEn},
|
||||
"isVerified" = ${entity.isVerified},
|
||||
"updatedAt" = ${entity.updatedAt}
|
||||
WHERE id = ${entity.id}
|
||||
`;
|
||||
}
|
||||
|
||||
async search(params: IndustrialParkSearchParams): Promise<PaginatedResult<IndustrialParkListItem>> {
|
||||
const page = params.page ?? 1;
|
||||
const limit = params.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions: string[] = ['1=1'];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (params.province) {
|
||||
conditions.push(`province = $${paramIndex++}`);
|
||||
values.push(params.province);
|
||||
}
|
||||
if (params.region) {
|
||||
conditions.push(`region = $${paramIndex++}::"VietnamRegion"`);
|
||||
values.push(params.region);
|
||||
}
|
||||
if (params.status) {
|
||||
conditions.push(`status = $${paramIndex++}::"IndustrialParkStatus"`);
|
||||
values.push(params.status);
|
||||
}
|
||||
if (params.minAreaHa != null) {
|
||||
conditions.push(`"remainingAreaHa" >= $${paramIndex++}`);
|
||||
values.push(params.minAreaHa);
|
||||
}
|
||||
if (params.maxRentUsdM2 != null) {
|
||||
conditions.push(`"landRentUsdM2Year" <= $${paramIndex++}`);
|
||||
values.push(params.maxRentUsdM2);
|
||||
}
|
||||
if (params.targetIndustry) {
|
||||
conditions.push(`$${paramIndex++} = ANY("targetIndustries")`);
|
||||
values.push(params.targetIndustry);
|
||||
}
|
||||
if (params.query) {
|
||||
conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`);
|
||||
values.push(`%${params.query}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const countResult = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
||||
`SELECT COUNT(*)::bigint as count FROM "IndustrialPark" WHERE ${where}`,
|
||||
...values,
|
||||
);
|
||||
const total = Number(countResult[0].count);
|
||||
|
||||
const rows = await this.prisma.$queryRawUnsafe<RawPark[]>(
|
||||
`SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||
FROM "IndustrialPark" WHERE ${where}
|
||||
ORDER BY "occupancyRate" DESC, "createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
||||
...values, limit, offset,
|
||||
);
|
||||
|
||||
return {
|
||||
data: rows.map((r) => this.toListItem(r)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async compareParks(ids: string[]): Promise<IndustrialParkDetailData[]> {
|
||||
const rows = await this.prisma.$queryRaw<RawParkDetail[]>`
|
||||
SELECT p.*,
|
||||
ST_Y(p.location::geometry) as lat,
|
||||
ST_X(p.location::geometry) as lng,
|
||||
COUNT(l.id)::int as "listingCount"
|
||||
FROM "IndustrialPark" p
|
||||
LEFT JOIN "IndustrialListing" l ON l."parkId" = p.id AND l.status = 'ACTIVE'
|
||||
WHERE p.id = ANY(${ids}::text[])
|
||||
GROUP BY p.id
|
||||
`;
|
||||
return rows.map((r) => this.toDetail(r));
|
||||
}
|
||||
|
||||
async getStats(): Promise<IndustrialParkStatsData> {
|
||||
const [summary] = await this.prisma.$queryRaw<[{ totalParks: bigint; totalAreaHa: number; avgOccupancy: number; totalTenants: bigint }]>`
|
||||
SELECT COUNT(*)::bigint as "totalParks",
|
||||
COALESCE(SUM("totalAreaHa"), 0) as "totalAreaHa",
|
||||
COALESCE(AVG("occupancyRate"), 0) as "avgOccupancy",
|
||||
COALESCE(SUM("tenantCount"), 0)::bigint as "totalTenants"
|
||||
FROM "IndustrialPark"
|
||||
`;
|
||||
|
||||
const byRegion = await this.prisma.$queryRaw<{ region: string; count: bigint; avgOccupancy: number }[]>`
|
||||
SELECT region::text, COUNT(*)::bigint as count, AVG("occupancyRate") as "avgOccupancy"
|
||||
FROM "IndustrialPark" GROUP BY region ORDER BY count DESC
|
||||
`;
|
||||
|
||||
const byStatus = await this.prisma.$queryRaw<{ status: string; count: bigint }[]>`
|
||||
SELECT status::text, COUNT(*)::bigint as count
|
||||
FROM "IndustrialPark" GROUP BY status ORDER BY count DESC
|
||||
`;
|
||||
|
||||
const topProvinces = await this.prisma.$queryRaw<{ province: string; count: bigint; avgRent: number | null }[]>`
|
||||
SELECT province, COUNT(*)::bigint as count, AVG("landRentUsdM2Year") as "avgRent"
|
||||
FROM "IndustrialPark" GROUP BY province ORDER BY count DESC LIMIT 10
|
||||
`;
|
||||
|
||||
return {
|
||||
totalParks: Number(summary.totalParks),
|
||||
totalAreaHa: summary.totalAreaHa,
|
||||
avgOccupancyRate: summary.avgOccupancy,
|
||||
totalTenants: Number(summary.totalTenants),
|
||||
byRegion: byRegion.map((r) => ({ region: r.region, count: Number(r.count), avgOccupancy: r.avgOccupancy })),
|
||||
byStatus: byStatus.map((r) => ({ status: r.status, count: Number(r.count) })),
|
||||
topProvinces: topProvinces.map((r) => ({ province: r.province, count: Number(r.count), avgRent: r.avgRent })),
|
||||
};
|
||||
}
|
||||
|
||||
async getMarketData(): Promise<IndustrialMarketData> {
|
||||
const [overall] = await this.prisma.$queryRaw<[{ totalParks: bigint; avgOccupancy: number; avgLandRent: number | null; avgRbfRent: number | null }]>`
|
||||
SELECT COUNT(*)::bigint as "totalParks",
|
||||
AVG("occupancyRate") as "avgOccupancy",
|
||||
AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
AVG("rbfRentUsdM2Month") as "avgRbfRent"
|
||||
FROM "IndustrialPark" WHERE status = 'OPERATIONAL' OR status = 'FULL'
|
||||
`;
|
||||
|
||||
const rentByRegion = await this.prisma.$queryRaw<{ region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>`
|
||||
SELECT region::text, AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
|
||||
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
|
||||
GROUP BY region ORDER BY "avgLandRent" DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const rentByProvince = await this.prisma.$queryRaw<{ province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>`
|
||||
SELECT province, AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
|
||||
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
|
||||
GROUP BY province ORDER BY "avgLandRent" DESC NULLS LAST
|
||||
`;
|
||||
|
||||
return {
|
||||
totalParks: Number(overall.totalParks),
|
||||
avgOccupancyRate: overall.avgOccupancy,
|
||||
avgLandRentUsdM2: overall.avgLandRent,
|
||||
avgRbfRentUsdM2: overall.avgRbfRent,
|
||||
rentByRegion: rentByRegion.map((r) => ({ ...r, parkCount: Number(r.parkCount) })),
|
||||
rentByProvince: rentByProvince.map((r) => ({ ...r, parkCount: Number(r.parkCount) })),
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(row: RawPark): IndustrialParkEntity {
|
||||
return new IndustrialParkEntity(
|
||||
row.id,
|
||||
{
|
||||
name: row.name,
|
||||
nameEn: row.nameEn,
|
||||
slug: row.slug,
|
||||
developer: row.developer,
|
||||
operator: row.operator,
|
||||
status: row.status,
|
||||
latitude: Number(row.lat),
|
||||
longitude: Number(row.lng),
|
||||
address: row.address,
|
||||
district: row.district,
|
||||
province: row.province,
|
||||
region: row.region,
|
||||
totalAreaHa: row.totalAreaHa,
|
||||
leasableAreaHa: row.leasableAreaHa,
|
||||
occupancyRate: row.occupancyRate,
|
||||
remainingAreaHa: row.remainingAreaHa,
|
||||
tenantCount: row.tenantCount,
|
||||
establishedYear: row.establishedYear,
|
||||
landRentUsdM2Year: row.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: row.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: row.rbwRentUsdM2Month,
|
||||
managementFeeUsd: row.managementFeeUsd,
|
||||
infrastructure: row.infrastructure as Record<string, unknown> | null,
|
||||
connectivity: row.connectivity as Record<string, unknown> | null,
|
||||
incentives: row.incentives as Record<string, unknown> | null,
|
||||
targetIndustries: row.targetIndustries ?? [],
|
||||
existingTenants: row.existingTenants as Record<string, unknown>[] | null,
|
||||
certifications: row.certifications as string[] | null,
|
||||
media: row.media as Record<string, unknown>[] | null,
|
||||
documents: row.documents as Record<string, unknown>[] | null,
|
||||
description: row.description,
|
||||
descriptionEn: row.descriptionEn,
|
||||
isVerified: row.isVerified,
|
||||
},
|
||||
row.createdAt,
|
||||
row.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private toListItem(row: RawPark): IndustrialParkListItem {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
nameEn: row.nameEn,
|
||||
slug: row.slug,
|
||||
developer: row.developer,
|
||||
status: row.status,
|
||||
province: row.province,
|
||||
region: row.region,
|
||||
totalAreaHa: row.totalAreaHa,
|
||||
occupancyRate: row.occupancyRate,
|
||||
remainingAreaHa: row.remainingAreaHa,
|
||||
tenantCount: row.tenantCount,
|
||||
landRentUsdM2Year: row.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: row.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: row.rbwRentUsdM2Month,
|
||||
targetIndustries: row.targetIndustries ?? [],
|
||||
latitude: Number(row.lat),
|
||||
longitude: Number(row.lng),
|
||||
};
|
||||
}
|
||||
|
||||
private toDetail(row: RawParkDetail): IndustrialParkDetailData {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
nameEn: row.nameEn,
|
||||
slug: row.slug,
|
||||
developer: row.developer,
|
||||
operator: row.operator,
|
||||
status: row.status,
|
||||
latitude: Number(row.lat),
|
||||
longitude: Number(row.lng),
|
||||
address: row.address,
|
||||
district: row.district,
|
||||
province: row.province,
|
||||
region: row.region,
|
||||
totalAreaHa: row.totalAreaHa,
|
||||
leasableAreaHa: row.leasableAreaHa,
|
||||
occupancyRate: row.occupancyRate,
|
||||
remainingAreaHa: row.remainingAreaHa,
|
||||
tenantCount: row.tenantCount,
|
||||
establishedYear: row.establishedYear,
|
||||
landRentUsdM2Year: row.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: row.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: row.rbwRentUsdM2Month,
|
||||
managementFeeUsd: row.managementFeeUsd,
|
||||
infrastructure: row.infrastructure as Record<string, unknown> | null,
|
||||
connectivity: row.connectivity as Record<string, unknown> | null,
|
||||
incentives: row.incentives as Record<string, unknown> | null,
|
||||
targetIndustries: row.targetIndustries ?? [],
|
||||
existingTenants: row.existingTenants as Record<string, unknown>[] | null,
|
||||
certifications: row.certifications as string[] | null,
|
||||
media: row.media as Record<string, unknown>[] | null,
|
||||
documents: row.documents as Record<string, unknown>[] | null,
|
||||
description: row.description,
|
||||
descriptionEn: row.descriptionEn,
|
||||
isVerified: row.isVerified,
|
||||
listingCount: row.listingCount ?? 0,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Raw query result types
|
||||
interface RawPark {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
operator: string | null;
|
||||
status: 'PLANNING' | 'UNDER_CONSTRUCTION' | 'OPERATIONAL' | 'FULL';
|
||||
lat: number;
|
||||
lng: number;
|
||||
address: string;
|
||||
district: string;
|
||||
province: string;
|
||||
region: 'NORTH' | 'CENTRAL' | 'SOUTH';
|
||||
totalAreaHa: number;
|
||||
leasableAreaHa: number;
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
infrastructure: Prisma.JsonValue;
|
||||
connectivity: Prisma.JsonValue;
|
||||
incentives: Prisma.JsonValue;
|
||||
targetIndustries: string[] | null;
|
||||
existingTenants: Prisma.JsonValue;
|
||||
certifications: Prisma.JsonValue;
|
||||
media: Prisma.JsonValue;
|
||||
documents: Prisma.JsonValue;
|
||||
description: string | null;
|
||||
descriptionEn: string | null;
|
||||
isVerified: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface RawParkDetail extends RawPark {
|
||||
listingCount: number;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { type TypesenseClientService } from '@modules/search';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
|
||||
export const INDUSTRIAL_PARKS_COLLECTION = 'industrial_parks';
|
||||
export const INDUSTRIAL_LISTINGS_COLLECTION = 'industrial_listings';
|
||||
|
||||
const PARKS_SCHEMA: CollectionCreateSchema = {
|
||||
name: INDUSTRIAL_PARKS_COLLECTION,
|
||||
fields: [
|
||||
{ name: 'parkId', type: 'string', facet: false },
|
||||
{ name: 'name', type: 'string', facet: false },
|
||||
{ name: 'nameEn', type: 'string', facet: false, optional: true },
|
||||
{ name: 'slug', type: 'string', facet: false },
|
||||
{ name: 'developer', type: 'string', facet: true },
|
||||
{ name: 'operator', type: 'string', facet: true, optional: true },
|
||||
{ name: 'status', type: 'string', facet: true },
|
||||
{ name: 'province', type: 'string', facet: true },
|
||||
{ name: 'region', type: 'string', facet: true },
|
||||
{ name: 'totalAreaHa', type: 'float', facet: false },
|
||||
{ name: 'leasableAreaHa', type: 'float', facet: false },
|
||||
{ name: 'remainingAreaHa', type: 'float', facet: false },
|
||||
{ name: 'occupancyRate', type: 'float', facet: false },
|
||||
{ name: 'tenantCount', type: 'int32', facet: false },
|
||||
{ name: 'landRentUsdM2Year', type: 'float', facet: false, optional: true },
|
||||
{ name: 'rbfRentUsdM2Month', type: 'float', facet: false, optional: true },
|
||||
{ name: 'rbwRentUsdM2Month', type: 'float', facet: false, optional: true },
|
||||
{ name: 'targetIndustries', type: 'string[]', facet: true },
|
||||
{ name: 'hasReadyBuilt', type: 'bool', facet: true },
|
||||
{ name: 'location', type: 'geopoint', facet: false },
|
||||
{ name: 'isVerified', type: 'bool', facet: true },
|
||||
{ name: 'createdAt', type: 'int64', facet: false },
|
||||
],
|
||||
token_separators: ['-', '_'],
|
||||
enable_nested_fields: false,
|
||||
};
|
||||
|
||||
const LISTINGS_SCHEMA: CollectionCreateSchema = {
|
||||
name: INDUSTRIAL_LISTINGS_COLLECTION,
|
||||
fields: [
|
||||
{ name: 'listingId', type: 'string', facet: false },
|
||||
{ name: 'title', type: 'string', facet: false },
|
||||
{ name: 'description', type: 'string', facet: false, optional: true },
|
||||
{ name: 'parkName', type: 'string', facet: true },
|
||||
{ name: 'parkId', type: 'string', facet: true },
|
||||
{ name: 'propertyType', type: 'string', facet: true },
|
||||
{ name: 'leaseType', type: 'string', facet: true },
|
||||
{ name: 'province', type: 'string', facet: true },
|
||||
{ name: 'region', type: 'string', facet: true },
|
||||
{ name: 'areaM2', type: 'float', facet: false },
|
||||
{ name: 'priceUsdM2', type: 'float', facet: false, optional: true },
|
||||
{ name: 'ceilingHeightM', type: 'float', facet: false, optional: true },
|
||||
{ name: 'floorLoadTonM2', type: 'float', facet: false, optional: true },
|
||||
{ name: 'targetIndustries', type: 'string[]', facet: true },
|
||||
{ name: 'location', type: 'geopoint', facet: false },
|
||||
{ name: 'occupancyRate', type: 'float', facet: false },
|
||||
{ name: 'status', type: 'string', facet: true },
|
||||
{ name: 'publishedAt', type: 'int64', facet: false, optional: true },
|
||||
],
|
||||
token_separators: ['-', '_'],
|
||||
enable_nested_fields: false,
|
||||
};
|
||||
|
||||
interface RawIndustrialPark {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
developer: string;
|
||||
operator: string | null;
|
||||
status: string;
|
||||
province: string;
|
||||
region: string;
|
||||
totalAreaHa: number;
|
||||
leasableAreaHa: number;
|
||||
remainingAreaHa: number;
|
||||
occupancyRate: number;
|
||||
tenantCount: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
targetIndustries: string[] | null;
|
||||
isVerified: boolean;
|
||||
lat: number;
|
||||
lng: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TypesenseIndustrialService implements OnModuleInit {
|
||||
private client: TypesenseClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly typesenseClient: TypesenseClientService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
this.client = this.typesenseClient.getClient();
|
||||
await this.ensureCollections();
|
||||
await this.syncParks();
|
||||
} catch (err) {
|
||||
this.logger.warn(`Typesense industrial init failed (non-fatal): ${err}`, 'TypesenseIndustrial');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCollections(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
for (const schema of [PARKS_SCHEMA, LISTINGS_SCHEMA]) {
|
||||
try {
|
||||
await this.client.collections(schema.name).retrieve();
|
||||
this.logger.log(`Collection "${schema.name}" exists`, 'TypesenseIndustrial');
|
||||
} catch {
|
||||
await this.client.collections().create(schema);
|
||||
this.logger.log(`Collection "${schema.name}" created`, 'TypesenseIndustrial');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncParks(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
const parks = await this.prisma.$queryRaw<RawIndustrialPark[]>`
|
||||
SELECT id, name, "nameEn", slug, developer, operator, status::text,
|
||||
province, region::text, "totalAreaHa", "leasableAreaHa", "remainingAreaHa",
|
||||
"occupancyRate", "tenantCount", "landRentUsdM2Year", "rbfRentUsdM2Month",
|
||||
"rbwRentUsdM2Month", "targetIndustries", "isVerified",
|
||||
ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng,
|
||||
"createdAt"
|
||||
FROM "IndustrialPark"
|
||||
`;
|
||||
|
||||
if (parks.length === 0) return;
|
||||
|
||||
const docs = parks.map((p) => ({
|
||||
id: p.id,
|
||||
parkId: p.id,
|
||||
name: p.name,
|
||||
nameEn: p.nameEn ?? undefined,
|
||||
slug: p.slug,
|
||||
developer: p.developer,
|
||||
operator: p.operator ?? undefined,
|
||||
status: p.status.toLowerCase(),
|
||||
province: p.province,
|
||||
region: p.region.toLowerCase(),
|
||||
totalAreaHa: p.totalAreaHa,
|
||||
leasableAreaHa: p.leasableAreaHa,
|
||||
remainingAreaHa: p.remainingAreaHa,
|
||||
occupancyRate: p.occupancyRate,
|
||||
tenantCount: p.tenantCount,
|
||||
landRentUsdM2Year: p.landRentUsdM2Year ?? undefined,
|
||||
rbfRentUsdM2Month: p.rbfRentUsdM2Month ?? undefined,
|
||||
rbwRentUsdM2Month: p.rbwRentUsdM2Month ?? undefined,
|
||||
targetIndustries: p.targetIndustries ?? [],
|
||||
hasReadyBuilt: (p.rbfRentUsdM2Month ?? 0) > 0 || (p.rbwRentUsdM2Month ?? 0) > 0,
|
||||
location: [Number(p.lat), Number(p.lng)],
|
||||
isVerified: p.isVerified,
|
||||
createdAt: Math.floor(p.createdAt.getTime() / 1000),
|
||||
}));
|
||||
|
||||
try {
|
||||
const jsonl = docs.map((d) => JSON.stringify(d)).join('\n');
|
||||
await this.client.collections(INDUSTRIAL_PARKS_COLLECTION).documents().import(jsonl, { action: 'upsert' });
|
||||
this.logger.log(`Synced ${docs.length} parks to Typesense`, 'TypesenseIndustrial');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Park sync error: ${err}`, 'TypesenseIndustrial');
|
||||
}
|
||||
}
|
||||
|
||||
async indexPark(parkId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
const [park] = await this.prisma.$queryRaw<RawIndustrialPark[]>`
|
||||
SELECT id, name, "nameEn", slug, developer, operator, status::text,
|
||||
province, region::text, "totalAreaHa", "leasableAreaHa", "remainingAreaHa",
|
||||
"occupancyRate", "tenantCount", "landRentUsdM2Year", "rbfRentUsdM2Month",
|
||||
"rbwRentUsdM2Month", "targetIndustries", "isVerified",
|
||||
ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng,
|
||||
"createdAt"
|
||||
FROM "IndustrialPark" WHERE id = ${parkId}
|
||||
`;
|
||||
|
||||
if (!park) return;
|
||||
|
||||
const doc = {
|
||||
id: park.id,
|
||||
parkId: park.id,
|
||||
name: park.name,
|
||||
nameEn: park.nameEn ?? undefined,
|
||||
slug: park.slug,
|
||||
developer: park.developer,
|
||||
operator: park.operator ?? undefined,
|
||||
status: park.status.toLowerCase(),
|
||||
province: park.province,
|
||||
region: park.region.toLowerCase(),
|
||||
totalAreaHa: park.totalAreaHa,
|
||||
leasableAreaHa: park.leasableAreaHa,
|
||||
remainingAreaHa: park.remainingAreaHa,
|
||||
occupancyRate: park.occupancyRate,
|
||||
tenantCount: park.tenantCount,
|
||||
landRentUsdM2Year: park.landRentUsdM2Year ?? undefined,
|
||||
rbfRentUsdM2Month: park.rbfRentUsdM2Month ?? undefined,
|
||||
rbwRentUsdM2Month: park.rbwRentUsdM2Month ?? undefined,
|
||||
targetIndustries: park.targetIndustries ?? [],
|
||||
hasReadyBuilt: (park.rbfRentUsdM2Month ?? 0) > 0 || (park.rbwRentUsdM2Month ?? 0) > 0,
|
||||
location: [Number(park.lat), Number(park.lng)],
|
||||
isVerified: park.isVerified,
|
||||
createdAt: Math.floor(park.createdAt.getTime() / 1000),
|
||||
};
|
||||
|
||||
await this.client.collections(INDUSTRIAL_PARKS_COLLECTION).documents().upsert(doc);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||
|
||||
export class CompareIndustrialParksDto {
|
||||
@ApiProperty({
|
||||
example: ['seed-kcn-001', 'seed-kcn-003', 'seed-kcn-005'],
|
||||
description: 'Danh sách ID KCN so sánh (2-5)',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(5)
|
||||
ids!: string[];
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IndustrialParkStatus, VietnamRegion } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateIndustrialParkDto {
|
||||
@ApiProperty({ example: 'KCN VSIP Hải Phòng', description: 'Tên KCN' })
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'VSIP Hai Phong Industrial Park' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@ApiProperty({ example: 'vsip-hai-phong', description: 'URL slug (unique)' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: 'VSIP Group' })
|
||||
@IsString()
|
||||
developer!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'VSIP Joint Venture' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
operator?: string;
|
||||
|
||||
@ApiProperty({ enum: IndustrialParkStatus, example: 'OPERATIONAL' })
|
||||
@IsEnum(IndustrialParkStatus)
|
||||
status!: IndustrialParkStatus;
|
||||
|
||||
@ApiProperty({ example: 20.8312, description: 'Latitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.7198, description: 'Longitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ example: 'Phường Đông Hải, Quận Hải An' })
|
||||
@IsString()
|
||||
address!: string;
|
||||
|
||||
@ApiProperty({ example: 'Hải An' })
|
||||
@IsString()
|
||||
district!: string;
|
||||
|
||||
@ApiProperty({ example: 'Hải Phòng' })
|
||||
@IsString()
|
||||
province!: string;
|
||||
|
||||
@ApiProperty({ enum: VietnamRegion, example: 'NORTH' })
|
||||
@IsEnum(VietnamRegion)
|
||||
region!: VietnamRegion;
|
||||
|
||||
@ApiProperty({ example: 1200, description: 'Tổng diện tích (ha)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
totalAreaHa!: number;
|
||||
|
||||
@ApiProperty({ example: 900, description: 'Diện tích cho thuê (ha)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
leasableAreaHa!: number;
|
||||
|
||||
@ApiProperty({ example: 75, description: 'Tỷ lệ lấp đầy (0-100)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
occupancyRate!: number;
|
||||
|
||||
@ApiProperty({ example: 225, description: 'Diện tích còn trống (ha)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
remainingAreaHa!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 150 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
tenantCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2005 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
establishedYear?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 80, description: 'Giá thuê đất (USD/m²/năm)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
landRentUsdM2Year?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5.0, description: 'Giá thuê nhà xưởng xây sẵn (USD/m²/tháng)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
rbfRentUsdM2Month?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4.0, description: 'Giá thuê kho xây sẵn (USD/m²/tháng)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
rbwRentUsdM2Month?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.6 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
managementFeeUsd?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hạ tầng kỹ thuật' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
infrastructure?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Kết nối giao thông' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
connectivity?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ưu đãi đầu tư' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
incentives?: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ example: ['electronics', 'logistics'], description: 'Ngành mục tiêu' })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
targetIndustries!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mô tả (tiếng Việt)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mô tả (tiếng Anh)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionEn?: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IndustrialParkStatus, VietnamRegion } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class SearchIndustrialParksDto {
|
||||
@ApiPropertyOptional({ example: 'VSIP', description: 'Từ khóa tìm kiếm' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Bắc Ninh' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: VietnamRegion })
|
||||
@IsOptional()
|
||||
@IsEnum(VietnamRegion)
|
||||
region?: VietnamRegion;
|
||||
|
||||
@ApiPropertyOptional({ enum: IndustrialParkStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(IndustrialParkStatus)
|
||||
status?: IndustrialParkStatus;
|
||||
|
||||
@ApiPropertyOptional({ example: 50, description: 'Diện tích trống tối thiểu (ha)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
minAreaHa?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 100, description: 'Giá thuê đất tối đa (USD/m²/năm)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
maxRentUsdM2?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'electronics' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetIndustry?: 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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IndustrialParkStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateIndustrialParkDto {
|
||||
@ApiPropertyOptional({ example: 'KCN VSIP Hải Phòng II' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'VSIP Hai Phong II Industrial Park' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'VSIP Group' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
developer?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'VSIP Joint Venture' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
operator?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: IndustrialParkStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(IndustrialParkStatus)
|
||||
status?: IndustrialParkStatus;
|
||||
|
||||
@ApiPropertyOptional({ example: 80 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
occupancyRate?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 180 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
remainingAreaHa?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 160 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
tenantCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 85 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
landRentUsdM2Year?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5.5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
rbfRentUsdM2Month?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4.5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
rbwRentUsdM2Month?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.7 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
managementFeeUsd?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
infrastructure?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
connectivity?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
incentives?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: ['electronics', 'logistics'] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
targetIndustries?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionEn?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVerified?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user