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:
@@ -30,7 +30,7 @@ PGBOUNCER_STATS_PASSWORD=CHANGE_ME
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
|
||||
REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
|
||||
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typesense
|
||||
@@ -44,6 +44,7 @@ TYPESENSE_API_KEY=CHANGE_ME
|
||||
# MinIO (S3-compatible Object Storage)
|
||||
# -----------------------------------------------------------------------------
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_API_PORT=9000
|
||||
MINIO_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
MINIO_ACCESS_KEY=CHANGE_ME
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.89.0",
|
||||
"@aws-sdk/client-s3": "^3.1026.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1026.0",
|
||||
"@goodgo/mcp-servers": "workspace:*",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
@@ -37,6 +39,7 @@
|
||||
"@sentry/profiling-node": "^10.47.0",
|
||||
"@willsoto/nestjs-prometheus": "^6.1.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.74.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -54,6 +57,7 @@
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"puppeteer": "^24.41.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
@@ -9,6 +10,7 @@ import { AgentsModule } from '@modules/agents';
|
||||
import { AnalyticsModule } from '@modules/analytics';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { HealthModule } from '@modules/health';
|
||||
import { IndustrialModule } from '@modules/industrial';
|
||||
import { InquiriesModule } from '@modules/inquiries';
|
||||
import { LeadsModule } from '@modules/leads';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
@@ -17,6 +19,7 @@ import { MessagingModule } from '@modules/messaging';
|
||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||
import { NotificationsModule } from '@modules/notifications';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { ReportsModule } from '@modules/reports';
|
||||
import { ReviewsModule } from '@modules/reviews';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { SharedModule } from '@modules/shared';
|
||||
@@ -24,11 +27,19 @@ import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards
|
||||
import { CsrfMiddleware } from '@modules/shared/infrastructure/middleware/csrf.middleware';
|
||||
import { SanitizeInputMiddleware } from '@modules/shared/infrastructure/middleware/sanitize-input.middleware';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
import { TransferModule } from '@modules/transfer';
|
||||
import { AppController } from './app.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
},
|
||||
}),
|
||||
CqrsModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
SharedModule,
|
||||
@@ -48,6 +59,9 @@ import { AppController } from './app.controller';
|
||||
MetricsModule,
|
||||
McpIntegrationModule,
|
||||
MessagingModule,
|
||||
ReportsModule,
|
||||
IndustrialModule,
|
||||
TransferModule,
|
||||
|
||||
// ── Rate Limiting ──
|
||||
// Default: 60 requests per 60 seconds per IP
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
|
||||
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
|
||||
@@ -9,6 +8,7 @@ import { BatchValuationHandler } from './application/queries/batch-valuation/bat
|
||||
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
|
||||
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
|
||||
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GetPriceTrendHandler } from './application/queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetValuationHandler } from './application/queries/get-valuation/get-valuation.handler';
|
||||
import { ValuationComparisonHandler } from './application/queries/valuation-comparison/valuation-comparison.handler';
|
||||
@@ -16,12 +16,12 @@ import { ValuationHistoryHandler } from './application/queries/valuation-history
|
||||
import { MARKET_INDEX_REPOSITORY } from './domain/repositories/market-index.repository';
|
||||
import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository';
|
||||
import { AVM_SERVICE } from './domain/services/avm-service';
|
||||
import { NEIGHBORHOOD_SCORE_SERVICE } from './domain/services/neighborhood-score.service';
|
||||
import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository';
|
||||
import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository';
|
||||
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
|
||||
import { HttpAVMService } from './infrastructure/services/http-avm.service';
|
||||
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
|
||||
import { NEIGHBORHOOD_SCORE_SERVICE } from './domain/services/neighborhood-score.service';
|
||||
import { NeighborhoodScoreServiceImpl } from './infrastructure/services/neighborhood-score.service';
|
||||
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
|
||||
import { AnalyticsController } from './presentation/controllers/analytics.controller';
|
||||
|
||||
@@ -20,16 +20,16 @@ import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatm
|
||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||
import { type MarketReportDto } from '../../application/queries/get-market-report/get-market-report.handler';
|
||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
|
||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
|
||||
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
|
||||
import { type ValuationDto } from '../../application/queries/get-valuation/get-valuation.handler';
|
||||
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
|
||||
import { type ValuationComparisonDto as ValuationComparisonResultDto } from '../../application/queries/valuation-comparison/valuation-comparison.handler';
|
||||
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
|
||||
import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
import { type ValuationHistoryDto as ValuationHistoryResultDto } from '../../application/queries/valuation-history/valuation-history.handler';
|
||||
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
|
||||
import { type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||
import { type BatchValuationDto } from '../dto/batch-valuation.dto';
|
||||
import { type GetDistrictStatsDto } from '../dto/get-district-stats.dto';
|
||||
import { type GetHeatmapDto } from '../dto/get-heatmap.dto';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type CommandBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CreatePaymentCommand } from '@modules/payments/application/commands/create-payment/create-payment.command';
|
||||
import type { CreatePaymentResult } from '@modules/payments/application/commands/create-payment/create-payment.handler';
|
||||
import { CreatePaymentCommand, type CreatePaymentResult } from '@modules/payments';
|
||||
import {
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MinLength,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -8,6 +8,10 @@ export { PAYMENT_REPOSITORY, IPaymentRepository } from './domain/repositories/pa
|
||||
// Gateway
|
||||
export { PAYMENT_GATEWAY_FACTORY, IPaymentGatewayFactory } from './infrastructure/services/payment-gateway.interface';
|
||||
|
||||
// Commands
|
||||
export { CreatePaymentCommand } from './application/commands/create-payment/create-payment.command';
|
||||
export type { CreatePaymentResult } from './application/commands/create-payment/create-payment.handler';
|
||||
|
||||
// Domain Events — Payment
|
||||
export { PaymentCompletedEvent } from './domain/events/payment-completed.event';
|
||||
export { PaymentFailedEvent } from './domain/events/payment-failed.event';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteReportCommand {
|
||||
constructor(
|
||||
public readonly reportId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { DeleteReportCommand } from './delete-report.command';
|
||||
|
||||
@CommandHandler(DeleteReportCommand)
|
||||
export class DeleteReportHandler implements ICommandHandler<DeleteReportCommand, void> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteReportCommand): Promise<void> {
|
||||
const report = await this.reportRepo.findById(command.reportId);
|
||||
|
||||
if (!report) {
|
||||
throw new NotFoundException('Báo cáo không tồn tại.');
|
||||
}
|
||||
|
||||
if (report.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa báo cáo này.');
|
||||
}
|
||||
|
||||
await this.reportRepo.delete(command.reportId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ReportType } from '../../../domain/enums/report-type.enum';
|
||||
|
||||
export class GenerateReportCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly type: ReportType,
|
||||
public readonly title: string,
|
||||
public readonly params: Record<string, unknown>,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type Queue } from 'bullmq';
|
||||
import { ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { GenerateReportCommand } from './generate-report.command';
|
||||
|
||||
export const REPORT_GENERATION_QUEUE = 'report-generation';
|
||||
|
||||
export interface GenerateReportResult {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
@CommandHandler(GenerateReportCommand)
|
||||
export class GenerateReportHandler implements ICommandHandler<GenerateReportCommand, GenerateReportResult> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
@InjectQueue(REPORT_GENERATION_QUEUE) private readonly reportQueue: Queue,
|
||||
) {}
|
||||
|
||||
async execute(command: GenerateReportCommand): Promise<GenerateReportResult> {
|
||||
const report = ReportEntity.createNew(
|
||||
createId(),
|
||||
command.userId,
|
||||
command.type,
|
||||
command.title,
|
||||
command.params,
|
||||
);
|
||||
|
||||
await this.reportRepo.save(report);
|
||||
|
||||
await this.reportQueue.add('generate', {
|
||||
reportId: report.id,
|
||||
}, {
|
||||
attempts: 2,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
});
|
||||
|
||||
return { reportId: report.id };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/modules/reports/application/index.ts
Normal file
8
apps/api/src/modules/reports/application/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { GenerateReportCommand } from './commands/generate-report/generate-report.command';
|
||||
export { GenerateReportHandler, REPORT_GENERATION_QUEUE } from './commands/generate-report/generate-report.handler';
|
||||
export { DeleteReportCommand } from './commands/delete-report/delete-report.command';
|
||||
export { DeleteReportHandler } from './commands/delete-report/delete-report.handler';
|
||||
export { GetReportQuery } from './queries/get-report/get-report.query';
|
||||
export { GetReportHandler } from './queries/get-report/get-report.handler';
|
||||
export { ListReportsQuery } from './queries/list-reports/list-reports.query';
|
||||
export { ListReportsHandler } from './queries/list-reports/list-reports.handler';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { GetReportQuery } from './get-report.query';
|
||||
|
||||
@QueryHandler(GetReportQuery)
|
||||
export class GetReportHandler implements IQueryHandler<GetReportQuery, ReportEntity> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReportQuery): Promise<ReportEntity> {
|
||||
const report = await this.reportRepo.findById(query.reportId);
|
||||
|
||||
if (!report) {
|
||||
throw new NotFoundException('Báo cáo không tồn tại.');
|
||||
}
|
||||
|
||||
if (report.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem báo cáo này.');
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetReportQuery {
|
||||
constructor(
|
||||
public readonly reportId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { ListReportsQuery } from './list-reports.query';
|
||||
|
||||
export interface ListReportsResult {
|
||||
reports: ReportEntity[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@QueryHandler(ListReportsQuery)
|
||||
export class ListReportsHandler implements IQueryHandler<ListReportsQuery, ListReportsResult> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListReportsQuery): Promise<ListReportsResult> {
|
||||
return this.reportRepo.findByUserId({
|
||||
userId: query.userId,
|
||||
type: query.type,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ReportType } from '../../../domain/enums/report-type.enum';
|
||||
|
||||
export class ListReportsQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly type?: ReportType,
|
||||
public readonly limit: number = 20,
|
||||
public readonly offset: number = 0,
|
||||
) {}
|
||||
}
|
||||
1
apps/api/src/modules/reports/domain/entities/index.ts
Normal file
1
apps/api/src/modules/reports/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ReportEntity, type ReportProps } from './report.entity';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
import { ReportStatus } from '../enums/report-status.enum';
|
||||
import { type ReportType } from '../enums/report-type.enum';
|
||||
|
||||
export interface ReportProps {
|
||||
userId: string;
|
||||
type: ReportType;
|
||||
title: string;
|
||||
params: Record<string, unknown>;
|
||||
content: Record<string, unknown> | null;
|
||||
pdfUrl: string | null;
|
||||
status: ReportStatus;
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
export class ReportEntity extends AggregateRoot<string> {
|
||||
private _userId: string;
|
||||
private _type: ReportType;
|
||||
private _title: string;
|
||||
private _params: Record<string, unknown>;
|
||||
private _content: Record<string, unknown> | null;
|
||||
private _pdfUrl: string | null;
|
||||
private _status: ReportStatus;
|
||||
private _errorMsg: string | null;
|
||||
|
||||
constructor(id: string, props: ReportProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._userId = props.userId;
|
||||
this._type = props.type;
|
||||
this._title = props.title;
|
||||
this._params = props.params;
|
||||
this._content = props.content;
|
||||
this._pdfUrl = props.pdfUrl;
|
||||
this._status = props.status;
|
||||
this._errorMsg = props.errorMsg;
|
||||
}
|
||||
|
||||
get userId(): string { return this._userId; }
|
||||
get type(): ReportType { return this._type; }
|
||||
get title(): string { return this._title; }
|
||||
get params(): Record<string, unknown> { return this._params; }
|
||||
get content(): Record<string, unknown> | null { return this._content; }
|
||||
get pdfUrl(): string | null { return this._pdfUrl; }
|
||||
get status(): ReportStatus { return this._status; }
|
||||
get errorMsg(): string | null { return this._errorMsg; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
userId: string,
|
||||
type: ReportType,
|
||||
title: string,
|
||||
params: Record<string, unknown>,
|
||||
): ReportEntity {
|
||||
return new ReportEntity(id, {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
params,
|
||||
content: null,
|
||||
pdfUrl: null,
|
||||
status: ReportStatus.GENERATING,
|
||||
errorMsg: null,
|
||||
});
|
||||
}
|
||||
|
||||
markReady(content: Record<string, unknown>, pdfUrl: string | null): void {
|
||||
this._content = content;
|
||||
this._pdfUrl = pdfUrl;
|
||||
this._status = ReportStatus.READY;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
markFailed(errorMsg: string): void {
|
||||
this._status = ReportStatus.FAILED;
|
||||
this._errorMsg = errorMsg;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
2
apps/api/src/modules/reports/domain/enums/index.ts
Normal file
2
apps/api/src/modules/reports/domain/enums/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ReportType } from './report-type.enum';
|
||||
export { ReportStatus } from './report-status.enum';
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ReportStatus {
|
||||
GENERATING = 'GENERATING',
|
||||
READY = 'READY',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum ReportType {
|
||||
RESIDENTIAL_MARKET = 'RESIDENTIAL_MARKET',
|
||||
INDUSTRIAL_MARKET = 'INDUSTRIAL_MARKET',
|
||||
DISTRICT_ANALYSIS = 'DISTRICT_ANALYSIS',
|
||||
INVESTMENT_FEASIBILITY = 'INVESTMENT_FEASIBILITY',
|
||||
INDUSTRIAL_LOCATION = 'INDUSTRIAL_LOCATION',
|
||||
PROPERTY_VALUATION = 'PROPERTY_VALUATION',
|
||||
PORTFOLIO = 'PORTFOLIO',
|
||||
}
|
||||
4
apps/api/src/modules/reports/domain/index.ts
Normal file
4
apps/api/src/modules/reports/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './enums';
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
@@ -0,0 +1 @@
|
||||
export { REPORT_REPOSITORY, type IReportRepository, type ListReportsFilter } from './report.repository';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ReportEntity } from '../entities/report.entity';
|
||||
import { type ReportStatus } from '../enums/report-status.enum';
|
||||
import { type ReportType } from '../enums/report-type.enum';
|
||||
|
||||
export const REPORT_REPOSITORY = Symbol('REPORT_REPOSITORY');
|
||||
|
||||
export interface ListReportsFilter {
|
||||
userId: string;
|
||||
type?: ReportType;
|
||||
status?: ReportStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface IReportRepository {
|
||||
findById(id: string): Promise<ReportEntity | null>;
|
||||
findByUserId(filter: ListReportsFilter): Promise<{ reports: ReportEntity[]; total: number }>;
|
||||
save(entity: ReportEntity): Promise<void>;
|
||||
update(entity: ReportEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const AI_NARRATIVE_SERVICE = Symbol('AI_NARRATIVE_SERVICE');
|
||||
|
||||
export interface NarrativeRequest {
|
||||
reportType: string;
|
||||
sectionKey: string;
|
||||
sectionTitle: string;
|
||||
context: Record<string, unknown>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface IAINarrativeService {
|
||||
generateNarrative(request: NarrativeRequest): Promise<string>;
|
||||
}
|
||||
6
apps/api/src/modules/reports/domain/services/index.ts
Normal file
6
apps/api/src/modules/reports/domain/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { REPORT_GENERATOR_SERVICE, type IReportGeneratorService, type ReportGenerationResult } from './report-generator.service';
|
||||
export { MACRO_DATA_SERVICE, type IMacroDataService, type MacroDataPoint } from './macro-data.service';
|
||||
export { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService, type InfrastructureProjectData } from './infrastructure-data.service';
|
||||
export { AI_NARRATIVE_SERVICE, type IAINarrativeService, type NarrativeRequest } from './ai-narrative.service';
|
||||
export { PDF_GENERATOR_SERVICE, type IPdfGeneratorService } from './pdf-generator.service';
|
||||
export { PDF_STORAGE_SERVICE, type IPdfStorageService } from './pdf-storage.service';
|
||||
@@ -0,0 +1,18 @@
|
||||
export const INFRASTRUCTURE_DATA_SERVICE = Symbol('INFRASTRUCTURE_DATA_SERVICE');
|
||||
|
||||
export interface InfrastructureProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
province: string;
|
||||
category: string;
|
||||
status: string;
|
||||
investmentVND: bigint | null;
|
||||
startDate: Date | null;
|
||||
completionDate: Date | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface IInfrastructureDataService {
|
||||
getByProvince(province: string, category?: string): Promise<InfrastructureProjectData[]>;
|
||||
getByStatus(status: string): Promise<InfrastructureProjectData[]>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const MACRO_DATA_SERVICE = Symbol('MACRO_DATA_SERVICE');
|
||||
|
||||
export interface MacroDataPoint {
|
||||
province: string;
|
||||
indicator: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
period: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface IMacroDataService {
|
||||
getByProvince(province: string, indicators?: string[]): Promise<MacroDataPoint[]>;
|
||||
getByIndicator(indicator: string, provinces?: string[]): Promise<MacroDataPoint[]>;
|
||||
upsert(data: Omit<MacroDataPoint, 'source'> & { source: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PDF_GENERATOR_SERVICE = Symbol('PDF_GENERATOR_SERVICE');
|
||||
|
||||
export interface IPdfGeneratorService {
|
||||
generatePdf(reportId: string, content: Record<string, unknown>): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PDF_STORAGE_SERVICE = Symbol('PDF_STORAGE_SERVICE');
|
||||
|
||||
export interface IPdfStorageService {
|
||||
uploadPdf(buffer: Buffer, reportId: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const REPORT_GENERATOR_SERVICE = Symbol('REPORT_GENERATOR_SERVICE');
|
||||
|
||||
export interface ReportGenerationResult {
|
||||
content: Record<string, unknown>;
|
||||
pdfUrl: string | null;
|
||||
}
|
||||
|
||||
export interface IReportGeneratorService {
|
||||
generate(reportId: string): Promise<void>;
|
||||
}
|
||||
4
apps/api/src/modules/reports/index.ts
Normal file
4
apps/api/src/modules/reports/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ReportsModule } from './reports.module';
|
||||
export { REPORT_REPOSITORY, type IReportRepository } from './domain/repositories/report.repository';
|
||||
export { MACRO_DATA_SERVICE, type IMacroDataService } from './domain/services/macro-data.service';
|
||||
export { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService } from './domain/services/infrastructure-data.service';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Prisma, type Report as PrismaReport } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ReportEntity } from '../../domain/entities/report.entity';
|
||||
import { type ReportStatus } from '../../domain/enums/report-status.enum';
|
||||
import { type ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { type IReportRepository, type ListReportsFilter } from '../../domain/repositories/report.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaReportRepository implements IReportRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<ReportEntity | null> {
|
||||
const row = await this.prisma.report.findUnique({ where: { id } });
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByUserId(filter: ListReportsFilter): Promise<{ reports: ReportEntity[]; total: number }> {
|
||||
const where: Record<string, unknown> = { userId: filter.userId };
|
||||
if (filter.type) where['type'] = filter.type;
|
||||
if (filter.status) where['status'] = filter.status;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.report.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: filter.limit ?? 20,
|
||||
skip: filter.offset ?? 0,
|
||||
}),
|
||||
this.prisma.report.count({ where }),
|
||||
]);
|
||||
|
||||
return { reports: rows.map((r) => this.toDomain(r)), total };
|
||||
}
|
||||
|
||||
async save(entity: ReportEntity): Promise<void> {
|
||||
await this.prisma.report.create({
|
||||
data: {
|
||||
id: entity.id,
|
||||
userId: entity.userId,
|
||||
type: entity.type as string as PrismaReport['type'],
|
||||
title: entity.title,
|
||||
params: entity.params as Prisma.InputJsonValue,
|
||||
content: entity.content as Prisma.InputJsonValue ?? undefined,
|
||||
pdfUrl: entity.pdfUrl,
|
||||
status: entity.status as string as PrismaReport['status'],
|
||||
errorMsg: entity.errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(entity: ReportEntity): Promise<void> {
|
||||
await this.prisma.report.update({
|
||||
where: { id: entity.id },
|
||||
data: {
|
||||
content: entity.content as Prisma.InputJsonValue ?? undefined,
|
||||
pdfUrl: entity.pdfUrl,
|
||||
status: entity.status as string as PrismaReport['status'],
|
||||
errorMsg: entity.errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.report.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise<number> {
|
||||
return this.prisma.report.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: periodStart, lt: periodEnd },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(row: PrismaReport): ReportEntity {
|
||||
return new ReportEntity(
|
||||
row.id,
|
||||
{
|
||||
userId: row.userId,
|
||||
type: row.type as string as ReportType,
|
||||
title: row.title,
|
||||
params: row.params as Record<string, unknown>,
|
||||
content: row.content as Record<string, unknown> | null,
|
||||
pdfUrl: row.pdfUrl,
|
||||
status: row.status as string as ReportStatus,
|
||||
errorMsg: row.errorMsg,
|
||||
},
|
||||
row.createdAt,
|
||||
row.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
type IAINarrativeService,
|
||||
type NarrativeRequest,
|
||||
} from '../../domain/services/ai-narrative.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeNarrativeService implements IAINarrativeService {
|
||||
private readonly logger = new Logger(ClaudeNarrativeService.name);
|
||||
private readonly client: Anthropic | null;
|
||||
private readonly model = 'claude-sonnet-4-20250514';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const apiKey = this.config.get<string>('CLAUDE_API_KEY');
|
||||
this.client = apiKey ? new Anthropic({ apiKey }) : null;
|
||||
|
||||
if (!this.client) {
|
||||
this.logger.warn('CLAUDE_API_KEY not configured — AI narratives will use fallback templates.');
|
||||
}
|
||||
}
|
||||
|
||||
async generateNarrative(request: NarrativeRequest): Promise<string> {
|
||||
if (!this.client) {
|
||||
return this.fallbackNarrative(request);
|
||||
}
|
||||
|
||||
const systemPrompt = this.buildSystemPrompt(request);
|
||||
const userPrompt = this.buildUserPrompt(request);
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n\n');
|
||||
|
||||
return text || this.fallbackNarrative(request);
|
||||
} catch (error) {
|
||||
this.logger.error(`Claude API error for ${request.sectionKey}: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
return this.fallbackNarrative(request);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(request: NarrativeRequest): string {
|
||||
const locale = request.locale ?? 'vi';
|
||||
return [
|
||||
'You are an expert Vietnamese real estate market analyst.',
|
||||
`Write in ${locale === 'vi' ? 'Vietnamese' : 'English'}.`,
|
||||
'Produce concise, data-driven analysis suitable for investors and professionals.',
|
||||
'Use bullet points and short paragraphs. Include specific numbers from the provided data.',
|
||||
'Do not repeat raw JSON — interpret the data and provide actionable insights.',
|
||||
'Format output as plain text (no markdown headers).',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
private buildUserPrompt(request: NarrativeRequest): string {
|
||||
const sectionPrompts: Record<string, string> = {
|
||||
executive_summary: `Write an executive summary for a ${request.reportType} report. Highlight 3-5 key findings from the data.`,
|
||||
risk_assessment: 'Analyze investment risks based on the economic indicators and infrastructure data. Cover: market risk, regulatory risk, liquidity risk, and macro risk.',
|
||||
recommendation: 'Provide 3-5 investment recommendations with specific rationale. Include suggested strategies and risk mitigation.',
|
||||
forecast: 'Provide a 6-12 month market forecast based on macro trends, supply-demand dynamics, and infrastructure pipeline.',
|
||||
industrial_landscape: 'Analyze the industrial park landscape: occupancy trends, rental rates, key tenants, and competitive positioning.',
|
||||
market_overview: 'Provide a market overview covering transaction volume, price trends, and key drivers for the period.',
|
||||
price_analysis: 'Analyze pricing trends, distribution by segment, and identify top-performing areas.',
|
||||
supply_demand: 'Analyze supply-demand balance: new inventory, absorption rate, and days-on-market trends.',
|
||||
};
|
||||
|
||||
const instruction = sectionPrompts[request.sectionKey]
|
||||
?? `Write analysis for the "${request.sectionTitle}" section of a ${request.reportType} report.`;
|
||||
|
||||
return `${instruction}\n\nData context:\n${JSON.stringify(request.context, null, 2)}`;
|
||||
}
|
||||
|
||||
private fallbackNarrative(request: NarrativeRequest): string {
|
||||
return `[${request.sectionTitle}] Phân tích tự động tạm thời không khả dụng. Dữ liệu thô có sẵn trong phần dữ liệu bên dưới.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type IInfrastructureDataService, type InfrastructureProjectData } from '../../domain/services/infrastructure-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaInfrastructureDataService implements IInfrastructureDataService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getByProvince(province: string, category?: string): Promise<InfrastructureProjectData[]> {
|
||||
const where: Record<string, unknown> = { province };
|
||||
if (category) where['category'] = category;
|
||||
|
||||
const rows = await this.prisma.infrastructureProject.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
province: r.province,
|
||||
category: r.category,
|
||||
status: r.status,
|
||||
investmentVND: r.investmentVND,
|
||||
startDate: r.startDate,
|
||||
completionDate: r.completionDate,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
|
||||
async getByStatus(status: string): Promise<InfrastructureProjectData[]> {
|
||||
const rows = await this.prisma.infrastructureProject.findMany({
|
||||
where: { status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
province: r.province,
|
||||
category: r.category,
|
||||
status: r.status,
|
||||
investmentVND: r.investmentVND,
|
||||
startDate: r.startDate,
|
||||
completionDate: r.completionDate,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type IMacroDataService, type MacroDataPoint } from '../../domain/services/macro-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaMacroDataService implements IMacroDataService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getByProvince(province: string, indicators?: string[]): Promise<MacroDataPoint[]> {
|
||||
const where: Record<string, unknown> = { province };
|
||||
if (indicators?.length) {
|
||||
where['indicator'] = { in: indicators };
|
||||
}
|
||||
|
||||
const rows = await this.prisma.macroeconomicData.findMany({
|
||||
where,
|
||||
orderBy: [{ indicator: 'asc' }, { period: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
province: r.province,
|
||||
indicator: r.indicator,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
period: r.period,
|
||||
source: r.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async getByIndicator(indicator: string, provinces?: string[]): Promise<MacroDataPoint[]> {
|
||||
const where: Record<string, unknown> = { indicator };
|
||||
if (provinces?.length) {
|
||||
where['province'] = { in: provinces };
|
||||
}
|
||||
|
||||
const rows = await this.prisma.macroeconomicData.findMany({
|
||||
where,
|
||||
orderBy: [{ province: 'asc' }, { period: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
province: r.province,
|
||||
indicator: r.indicator,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
period: r.period,
|
||||
source: r.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsert(data: MacroDataPoint): Promise<void> {
|
||||
await this.prisma.macroeconomicData.upsert({
|
||||
where: {
|
||||
province_indicator_period: {
|
||||
province: data.province,
|
||||
indicator: data.indicator,
|
||||
period: data.period,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
province: data.province,
|
||||
indicator: data.indicator,
|
||||
value: data.value,
|
||||
unit: data.unit,
|
||||
period: data.period,
|
||||
source: data.source,
|
||||
},
|
||||
update: {
|
||||
value: data.value,
|
||||
unit: data.unit,
|
||||
source: data.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MinioPdfStorageService implements IPdfStorageService {
|
||||
private readonly logger = new Logger(MinioPdfStorageService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly publicBaseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
|
||||
const port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
|
||||
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||
const protocol = useSSL ? 'https' : 'http';
|
||||
|
||||
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
|
||||
this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`;
|
||||
|
||||
this.s3 = new S3Client({
|
||||
endpoint: `${protocol}://${endpoint}:${port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env['MINIO_ACCESS_KEY'] || '',
|
||||
secretAccessKey: process.env['MINIO_SECRET_KEY'] || '',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
|
||||
const objectKey = `reports/${reportId}.pdf`;
|
||||
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: objectKey,
|
||||
Body: buffer,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: `inline; filename="report-${reportId}.pdf"`,
|
||||
}),
|
||||
);
|
||||
|
||||
const url = `${this.publicBaseUrl}/${objectKey}`;
|
||||
this.logger.log(`PDF uploaded: ${url}`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
|
||||
/** Vietnamese report type display names */
|
||||
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
RESIDENTIAL_MARKET: 'Thị trường nhà ở',
|
||||
INDUSTRIAL_MARKET: 'Thị trường công nghiệp',
|
||||
DISTRICT_ANALYSIS: 'Phân tích quận/huyện',
|
||||
INVESTMENT_FEASIBILITY: 'Phân tích khả thi đầu tư',
|
||||
INDUSTRIAL_LOCATION: 'Vị trí khu công nghiệp',
|
||||
PROPERTY_VALUATION: 'Định giá bất động sản',
|
||||
PORTFOLIO: 'Danh mục đầu tư',
|
||||
};
|
||||
|
||||
interface ChartDataPoint {
|
||||
period: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface ReportSection {
|
||||
title?: string;
|
||||
content?: string;
|
||||
data?: Record<string, unknown>;
|
||||
charts?: Record<string, ChartDataPoint[]>;
|
||||
projects?: Array<Record<string, unknown>>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PuppeteerPdfGenerationService implements IPdfGenerationService {
|
||||
private readonly logger = new Logger(PuppeteerPdfGenerationService.name);
|
||||
|
||||
async generatePdf(
|
||||
title: string,
|
||||
reportType: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<Buffer> {
|
||||
const generatedAt = (content['generatedAt'] as string) || new Date().toISOString();
|
||||
const sections = (content['sections'] as Record<string, ReportSection>) || {};
|
||||
const typeLabel = REPORT_TYPE_LABELS[reportType] || reportType;
|
||||
|
||||
const html = this.buildHtml(title, typeLabel, generatedAt, sections);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: `
|
||||
<div style="font-family: 'Be Vietnam Pro', Arial, sans-serif; font-size: 9px; color: #94a3b8; width: 100%; padding: 0 2cm; display: flex; justify-content: space-between;">
|
||||
<span>GoodGo AI Report — ${this.escapeHtml(title)}</span>
|
||||
<span>Trang <span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
this.logger.log(`PDF generated for report: ${title} (${pdfBuffer.length} bytes)`);
|
||||
return Buffer.from(pdfBuffer);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(
|
||||
title: string,
|
||||
typeLabel: string,
|
||||
generatedAt: string,
|
||||
sections: Record<string, ReportSection>,
|
||||
): string {
|
||||
const date = new Date(generatedAt);
|
||||
const formattedDate = date.toLocaleDateString('vi-VN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sectionEntries = Object.entries(sections);
|
||||
const tocHtml = this.buildToc(sectionEntries);
|
||||
const sectionsHtml = sectionEntries
|
||||
.map(([key, section], index) => this.buildSection(key, section, index + 1))
|
||||
.join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Be Vietnam Pro', Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── Cover page ── */
|
||||
.cover {
|
||||
page-break-after: always;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 85vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cover-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.cover-logo svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 12px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cover-type {
|
||||
font-size: 14pt;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cover-date {
|
||||
font-size: 11pt;
|
||||
color: #64748b;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
font-size: 10pt;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.ai-badge svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: #059669;
|
||||
}
|
||||
|
||||
/* ── TOC ── */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
}
|
||||
|
||||
.toc-num {
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.section-num {
|
||||
font-size: 14pt;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-content p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Charts ── */
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Data tables ── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f1f5f9;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* ── Methodology page ── */
|
||||
.methodology {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.methodology h2 {
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.methodology h3 {
|
||||
font-size: 12pt;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.methodology p, .methodology li {
|
||||
color: #475569;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.methodology ul {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.methodology li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.contact-box {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 24px;
|
||||
padding: 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
font-size: 9pt;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══ COVER PAGE ═══ -->
|
||||
<div class="cover">
|
||||
<div class="cover-logo">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<h1>${this.escapeHtml(title)}</h1>
|
||||
<div class="cover-type">${this.escapeHtml(typeLabel)}</div>
|
||||
<div class="cover-date">${this.escapeHtml(formattedDate)}</div>
|
||||
<div class="ai-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 017 7h1.27c.34-.6.99-1 1.73-1a2 2 0 010 4c-.74 0-1.39-.4-1.73-1H21a7 7 0 01-7 7v1.27c.6.34 1 .99 1 1.73a2 2 0 01-4 0c0-.74.4-1.39 1-1.73V23a7 7 0 01-7-7H3.73c-.34.6-.99 1-1.73 1a2 2 0 010-4c.74 0 1.39.4 1.73 1H5a7 7 0 017-7V5.73C11.4 5.39 11 4.74 11 4a2 2 0 012-2z"/></svg>
|
||||
Powered by AI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ TABLE OF CONTENTS ═══ -->
|
||||
<div class="toc">
|
||||
<h2>Mục lục</h2>
|
||||
${tocHtml}
|
||||
</div>
|
||||
|
||||
<!-- ═══ EXECUTIVE SUMMARY (first section, forced to its own page) ═══ -->
|
||||
${sectionsHtml}
|
||||
|
||||
<!-- ═══ METHODOLOGY & SOURCES ═══ -->
|
||||
<div class="methodology">
|
||||
<h2>Phương pháp & Nguồn dữ liệu</h2>
|
||||
|
||||
<h3>Phương pháp phân tích</h3>
|
||||
<ul>
|
||||
<li>Phân tích dữ liệu thị trường BĐS từ các sàn giao dịch lớn</li>
|
||||
<li>Mô hình định giá tự động (AVM) dựa trên machine learning</li>
|
||||
<li>Phân tích chuỗi thời gian cho dự báo xu hướng giá</li>
|
||||
<li>Xử lý ngôn ngữ tự nhiên (NLP) cho phân tích tin tức và sentiment</li>
|
||||
<li>Dữ liệu vĩ mô từ Tổng cục Thống kê và các nguồn chính thức</li>
|
||||
</ul>
|
||||
|
||||
<h3>Nguồn dữ liệu</h3>
|
||||
<ul>
|
||||
<li>GoodGo Platform — dữ liệu listings và giao dịch nội bộ</li>
|
||||
<li>Tổng cục Thống kê Việt Nam (GSO)</li>
|
||||
<li>Ngân hàng Nhà nước Việt Nam (SBV)</li>
|
||||
<li>Bộ Kế hoạch và Đầu tư — dữ liệu FDI</li>
|
||||
<li>OpenStreetMap & Google Maps — dữ liệu POI</li>
|
||||
</ul>
|
||||
|
||||
<h3>Liên hệ</h3>
|
||||
<div class="contact-box">
|
||||
<p><strong>GoodGo AI Research</strong></p>
|
||||
<p>Email: research@goodgo.vn</p>
|
||||
<p>Website: goodgo.vn</p>
|
||||
</div>
|
||||
|
||||
<div class="disclaimer">
|
||||
<strong>Miễn trừ trách nhiệm:</strong> Báo cáo này được tạo tự động bởi hệ thống AI của GoodGo và chỉ mang
|
||||
tính tham khảo. Các dữ liệu và phân tích không thay thế cho tư vấn chuyên nghiệp. GoodGo không chịu trách
|
||||
nhiệm cho bất kỳ quyết định đầu tư nào dựa trên nội dung báo cáo này.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildToc(entries: Array<[string, ReportSection]>): string {
|
||||
const items = entries
|
||||
.map(([, section], index) => {
|
||||
const title = section.title || 'Untitled';
|
||||
return `<li><span class="toc-num">${index + 1}.</span><span class="toc-title">${this.escapeHtml(title)}</span></li>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<ul class="toc-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
private buildSection(key: string, section: ReportSection, index: number): string {
|
||||
const title = section.title || key;
|
||||
const isExecutiveSummary = key === 'executive_summary';
|
||||
|
||||
let html = `<div class="section" ${isExecutiveSummary ? 'style="page-break-before: always;"' : ''}>`;
|
||||
html += `<div class="section-header">`;
|
||||
html += `<span class="section-num">${index}</span>`;
|
||||
html += `<span class="section-title">${this.escapeHtml(title)}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Text content
|
||||
if (section.content) {
|
||||
html += `<div class="section-content"><p>${this.escapeHtml(section.content)}</p></div>`;
|
||||
}
|
||||
|
||||
// Charts
|
||||
if (section.charts) {
|
||||
for (const [chartKey, chartData] of Object.entries(section.charts)) {
|
||||
if (Array.isArray(chartData) && chartData.length > 0) {
|
||||
html += this.buildChart(chartKey, chartData as ChartDataPoint[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data tables (from macro data or similar)
|
||||
if (section.data && typeof section.data === 'object') {
|
||||
html += this.buildDataTables(section.data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Infrastructure projects table
|
||||
if (section.projects && Array.isArray(section.projects)) {
|
||||
html += this.buildProjectsTable(section.projects);
|
||||
}
|
||||
|
||||
// Summary counts
|
||||
if (section.summary && typeof section.summary === 'object') {
|
||||
html += this.buildSummary(section.summary as Record<string, unknown>);
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildChart(chartKey: string, data: ChartDataPoint[]): string {
|
||||
const chartLabel = chartKey
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const unit = data[0]?.unit || '';
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const maxVal = Math.max(...values, 1);
|
||||
const minVal = Math.min(...values, 0);
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
const width = 500;
|
||||
const height = 200;
|
||||
const paddingLeft = 60;
|
||||
const paddingRight = 20;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 40;
|
||||
const chartWidth = width - paddingLeft - paddingRight;
|
||||
const chartHeight = height - paddingTop - paddingBottom;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = paddingLeft + (i / Math.max(data.length - 1, 1)) * chartWidth;
|
||||
const y = paddingTop + chartHeight - ((d.value - minVal) / range) * chartHeight;
|
||||
return { x, y, period: d.period, value: d.value };
|
||||
});
|
||||
|
||||
// Build SVG line chart
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
const areaD = pathD + ` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
|
||||
|
||||
// Y-axis labels (5 ticks)
|
||||
const yLabels = Array.from({ length: 5 }, (_, i) => {
|
||||
const val = minVal + (range * i) / 4;
|
||||
const y = paddingTop + chartHeight - (i / 4) * chartHeight;
|
||||
return { val, y };
|
||||
});
|
||||
|
||||
// X-axis labels (show max 6)
|
||||
const step = Math.max(1, Math.ceil(data.length / 6));
|
||||
const xLabels = data
|
||||
.filter((_, i) => i % step === 0 || i === data.length - 1)
|
||||
.map((d, _idx, _arr) => {
|
||||
const originalIdx = data.indexOf(d);
|
||||
const x = paddingLeft + (originalIdx / Math.max(data.length - 1, 1)) * chartWidth;
|
||||
return { x, label: d.period };
|
||||
});
|
||||
|
||||
const formatNum = (n: number): string => {
|
||||
if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
||||
if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||
return n.toFixed(1);
|
||||
};
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<!-- Grid lines -->
|
||||
${yLabels.map((yl) => `<line x1="${paddingLeft}" y1="${yl.y.toFixed(1)}" x2="${width - paddingRight}" y2="${yl.y.toFixed(1)}" stroke="#e2e8f0" stroke-width="1"/>`).join('\n')}
|
||||
|
||||
<!-- Area fill -->
|
||||
<path d="${areaD}" fill="url(#grad-${chartKey})" opacity="0.3"/>
|
||||
|
||||
<!-- Gradient -->
|
||||
<defs>
|
||||
<linearGradient id="grad-${chartKey}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Line -->
|
||||
<path d="${pathD}" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Data points -->
|
||||
${points.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="#059669" stroke="white" stroke-width="1.5"/>`).join('\n')}
|
||||
|
||||
<!-- Y-axis labels -->
|
||||
${yLabels.map((yl) => `<text x="${paddingLeft - 8}" y="${(yl.y + 4).toFixed(1)}" text-anchor="end" font-size="9" fill="#64748b" font-family="Be Vietnam Pro, Arial">${formatNum(yl.val)}</text>`).join('\n')}
|
||||
|
||||
<!-- X-axis labels -->
|
||||
${xLabels.map((xl) => `<text x="${xl.x.toFixed(1)}" y="${(paddingTop + chartHeight + 20).toFixed(1)}" text-anchor="middle" font-size="8" fill="#64748b" font-family="Be Vietnam Pro, Arial">${this.escapeHtml(xl.label)}</text>`).join('\n')}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">${this.escapeHtml(chartLabel)}${unit ? ` (${this.escapeHtml(unit)})` : ''}</div>
|
||||
${svg}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private buildDataTables(data: Record<string, unknown>): string {
|
||||
let html = '';
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (!Array.isArray(val) || val.length === 0) continue;
|
||||
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const items = val as ChartDataPoint[];
|
||||
|
||||
html += `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3">${this.escapeHtml(label)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kỳ</th>
|
||||
<th>Giá trị</th>
|
||||
<th>Đơn vị</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item) => `
|
||||
<tr>
|
||||
<td>${this.escapeHtml(String(item.period))}</td>
|
||||
<td>${typeof item.value === 'number' ? item.value.toLocaleString('vi-VN') : String(item.value)}</td>
|
||||
<td>${this.escapeHtml(String(item.unit || ''))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildProjectsTable(projects: Array<Record<string, unknown>>): string {
|
||||
if (projects.length === 0) return '';
|
||||
|
||||
return `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dự án</th>
|
||||
<th>Danh mục</th>
|
||||
<th>Trạng thái</th>
|
||||
<th>Vốn đầu tư (VND)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${projects.map((p) => `
|
||||
<tr>
|
||||
<td>${this.escapeHtml(String(p['name'] || ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['category'] || ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['status'] || ''))}</td>
|
||||
<td>${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') : '—'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
private buildSummary(summary: Record<string, unknown>): string {
|
||||
let html = '<div class="section-content">';
|
||||
|
||||
for (const [key, val] of Object.entries(summary)) {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
if (typeof val === 'number') {
|
||||
html += `<p><strong>${this.escapeHtml(label)}:</strong> ${val.toLocaleString('vi-VN')}</p>`;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
html += `<p><strong>${this.escapeHtml(label)}:</strong></p>`;
|
||||
html += '<ul style="margin-left: 20px; margin-bottom: 8px;">';
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
html += `<li>${this.escapeHtml(k)}: ${String(v)}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MinioPdfStorageService implements IPdfStorageService {
|
||||
private readonly logger = new Logger(MinioPdfStorageService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly endpoint: string;
|
||||
private readonly port: number;
|
||||
private readonly bucket: string;
|
||||
private readonly useSSL: boolean;
|
||||
|
||||
constructor() {
|
||||
const accessKey = process.env['MINIO_ACCESS_KEY'];
|
||||
const secretKey = process.env['MINIO_SECRET_KEY'];
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error('Missing MINIO_ACCESS_KEY or MINIO_SECRET_KEY environment variables');
|
||||
}
|
||||
|
||||
this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
|
||||
this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
|
||||
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
|
||||
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
|
||||
this.s3 = new S3Client({
|
||||
endpoint: `${protocol}://${this.endpoint}:${this.port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
|
||||
const objectKey = `reports/${reportId}/${Date.now()}-report.pdf`;
|
||||
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: objectKey,
|
||||
Body: buffer,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: 'inline',
|
||||
}),
|
||||
);
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
|
||||
|
||||
this.logger.log(`PDF uploaded: ${objectKey} (${buffer.length} bytes)`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
|
||||
interface ReportSection {
|
||||
title: string;
|
||||
content?: string;
|
||||
data?: Record<string, unknown>;
|
||||
projects?: Array<Record<string, unknown>>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PuppeteerPdfService implements IPdfGeneratorService {
|
||||
private readonly logger = new Logger(PuppeteerPdfService.name);
|
||||
|
||||
async generatePdf(reportId: string, content: Record<string, unknown>): Promise<string> {
|
||||
const html = this.buildHtml(content);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfPath = `/tmp/report-${reportId}.pdf`;
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
|
||||
printBackground: true,
|
||||
});
|
||||
|
||||
this.logger.log(`PDF generated: ${pdfPath}`);
|
||||
return pdfPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(content: Record<string, unknown>): string {
|
||||
const reportType = content['reportType'] as string ?? 'Báo cáo';
|
||||
const province = (content['province'] ?? content['city'] ?? '') as string;
|
||||
const generatedAt = content['generatedAt'] as string ?? new Date().toISOString();
|
||||
const sections = content['sections'] as Record<string, ReportSection> | undefined;
|
||||
|
||||
const sectionHtml = sections
|
||||
? Object.entries(sections).map(([key, section]) => this.renderSection(key, section)).join('\n')
|
||||
: '<p>Không có dữ liệu.</p>';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header h1 { font-size: 18pt; color: #2563eb; margin-bottom: 4px; }
|
||||
.header .meta { font-size: 9pt; color: #666; }
|
||||
.section { margin-bottom: 20px; page-break-inside: avoid; }
|
||||
.section h2 {
|
||||
font-size: 13pt;
|
||||
color: #2563eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section p { margin-bottom: 8px; white-space: pre-line; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
th, td { border: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; }
|
||||
th { background: #f3f4f6; font-weight: 600; }
|
||||
.data-grid { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; }
|
||||
.data-card {
|
||||
flex: 1 1 200px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.data-card h4 { font-size: 9pt; color: #666; margin-bottom: 4px; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 8px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>${this.escapeHtml(reportType)} — ${this.escapeHtml(province)}</h1>
|
||||
<div class="meta">GoodGo Platform AI | Ngày tạo: ${new Date(generatedAt).toLocaleDateString('vi-VN')}</div>
|
||||
</div>
|
||||
${sectionHtml}
|
||||
<div class="footer">
|
||||
© ${new Date().getFullYear()} GoodGo Platform — Báo cáo được tạo tự động bởi AI
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private renderSection(key: string, section: ReportSection): string {
|
||||
let body = '';
|
||||
|
||||
if (section.content) {
|
||||
body += `<p>${this.escapeHtml(section.content)}</p>`;
|
||||
}
|
||||
|
||||
if (section.projects && Array.isArray(section.projects) && section.projects.length > 0) {
|
||||
body += '<table><thead><tr><th>Tên</th><th>Loại</th><th>Trạng thái</th><th>Vốn đầu tư</th></tr></thead><tbody>';
|
||||
for (const p of section.projects) {
|
||||
body += `<tr>
|
||||
<td>${this.escapeHtml(String(p['name'] ?? ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['category'] ?? ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['status'] ?? ''))}</td>
|
||||
<td>${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') + ' VND' : '—'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
body += '</tbody></table>';
|
||||
}
|
||||
|
||||
if (section.data && typeof section.data === 'object' && !section.projects) {
|
||||
const entries = Object.entries(section.data);
|
||||
const firstEntry = entries[0];
|
||||
if (entries.length > 0 && firstEntry && Array.isArray(firstEntry[1])) {
|
||||
body += '<div class="data-grid">';
|
||||
for (const [indicator, values] of entries) {
|
||||
if (!Array.isArray(values) || values.length === 0) continue;
|
||||
const latest = values[values.length - 1];
|
||||
if (!latest) continue;
|
||||
const typed = latest as { period: string; value: number; unit: string };
|
||||
body += `<div class="data-card">
|
||||
<h4>${this.escapeHtml(indicator)}</h4>
|
||||
<strong>${typed.value.toLocaleString('vi-VN')} ${this.escapeHtml(typed.unit)}</strong>
|
||||
<br><span style="font-size:8pt;color:#999">${this.escapeHtml(typed.period)}</span>
|
||||
</div>`;
|
||||
}
|
||||
body += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="section" id="section-${key}"><h2>${this.escapeHtml(section.title)}</h2>${body}</div>`;
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import * as fs from 'fs';
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { type Job } from 'bullmq';
|
||||
import { REPORT_GENERATION_QUEUE } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../domain/repositories/report.repository';
|
||||
import { AI_NARRATIVE_SERVICE, type IAINarrativeService } from '../../domain/services/ai-narrative.service';
|
||||
import { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService } from '../../domain/services/infrastructure-data.service';
|
||||
import { MACRO_DATA_SERVICE, type IMacroDataService } from '../../domain/services/macro-data.service';
|
||||
import { PDF_GENERATOR_SERVICE, type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
import { PDF_STORAGE_SERVICE, type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
interface GenerateJobData {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
@Processor(REPORT_GENERATION_QUEUE)
|
||||
export class ReportGenerationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ReportGenerationProcessor.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
@Inject(MACRO_DATA_SERVICE) private readonly macroData: IMacroDataService,
|
||||
@Inject(INFRASTRUCTURE_DATA_SERVICE) private readonly infraData: IInfrastructureDataService,
|
||||
@Inject(AI_NARRATIVE_SERVICE) private readonly aiNarrative: IAINarrativeService,
|
||||
@Inject(PDF_GENERATOR_SERVICE) private readonly pdfGenerator: IPdfGeneratorService,
|
||||
@Inject(PDF_STORAGE_SERVICE) private readonly pdfStorage: IPdfStorageService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<GenerateJobData>): Promise<void> {
|
||||
const { reportId } = job.data;
|
||||
this.logger.log(`Processing report generation: ${reportId}`);
|
||||
|
||||
const report = await this.reportRepo.findById(reportId);
|
||||
if (!report) {
|
||||
this.logger.warn(`Report ${reportId} not found, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.generateContent(report.type, report.params);
|
||||
|
||||
// Generate and upload PDF
|
||||
let pdfUrl: string | null = null;
|
||||
try {
|
||||
const pdfPath = await this.pdfGenerator.generatePdf(reportId, content);
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
pdfUrl = await this.pdfStorage.uploadPdf(pdfBuffer, reportId);
|
||||
fs.unlinkSync(pdfPath);
|
||||
} catch (pdfErr) {
|
||||
this.logger.warn(`PDF generation failed for ${reportId}, completing without PDF: ${pdfErr instanceof Error ? pdfErr.message : 'Unknown'}`);
|
||||
}
|
||||
|
||||
report.markReady(content, pdfUrl);
|
||||
await this.reportRepo.update(report);
|
||||
this.logger.log(`Report ${reportId} generated successfully.`);
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Report ${reportId} generation failed: ${errMsg}`);
|
||||
report.markFailed(errMsg);
|
||||
await this.reportRepo.update(report);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateContent(
|
||||
type: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (type) {
|
||||
case ReportType.INDUSTRIAL_LOCATION:
|
||||
return this.generateIndustrialLocationReport(params);
|
||||
case ReportType.RESIDENTIAL_MARKET:
|
||||
return this.generateResidentialMarketReport(params);
|
||||
case ReportType.DISTRICT_ANALYSIS:
|
||||
return this.generateDistrictAnalysisReport(params);
|
||||
default:
|
||||
return this.generateGenericReport(type, params);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateIndustrialLocationReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const province = params['province'] as string;
|
||||
|
||||
const [macroData, infraProjects] = await Promise.all([
|
||||
this.macroData.getByProvince(province, ['gdp', 'fdi', 'population', 'urbanization', 'labor_force', 'avg_wage', 'industrial_output']),
|
||||
this.infraData.getByProvince(province),
|
||||
]);
|
||||
|
||||
const macroByIndicator: Record<string, Array<{ period: string; value: number; unit: string }>> = {};
|
||||
for (const dp of macroData) {
|
||||
const arr = macroByIndicator[dp.indicator] ??= [];
|
||||
arr.push({ period: dp.period, value: dp.value, unit: dp.unit });
|
||||
}
|
||||
|
||||
const infraSerialized = infraProjects.map((p) => ({
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
status: p.status,
|
||||
investmentVND: p.investmentVND?.toString() ?? null,
|
||||
completionDate: p.completionDate?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const dataContext = { province, macroByIndicator, infraProjects: infraSerialized };
|
||||
|
||||
// Generate AI narratives for sections that need analysis
|
||||
const [executiveSummary, industrialLandscape, riskAssessment, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'industrial_landscape',
|
||||
sectionTitle: 'Thị trường KCN',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'risk_assessment',
|
||||
sectionTitle: 'Đánh giá rủi ro',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị đầu tư',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
province,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: executiveSummary,
|
||||
},
|
||||
economic_indicators: {
|
||||
title: 'Chỉ số kinh tế',
|
||||
data: macroByIndicator,
|
||||
charts: {
|
||||
gdp_trend: macroByIndicator['gdp'] ?? [],
|
||||
fdi_trend: macroByIndicator['fdi'] ?? [],
|
||||
},
|
||||
},
|
||||
demographics: {
|
||||
title: 'Dân số & Lao động',
|
||||
data: {
|
||||
population: macroByIndicator['population'] ?? [],
|
||||
urbanization: macroByIndicator['urbanization'] ?? [],
|
||||
labor_force: macroByIndicator['labor_force'] ?? [],
|
||||
avg_wage: macroByIndicator['avg_wage'] ?? [],
|
||||
},
|
||||
},
|
||||
infrastructure: {
|
||||
title: 'Hạ tầng',
|
||||
projects: infraSerialized,
|
||||
summary: {
|
||||
total: infraProjects.length,
|
||||
byCategory: this.groupBy(infraProjects as unknown as Array<Record<string, unknown>>, 'category'),
|
||||
byStatus: this.groupBy(infraProjects as unknown as Array<Record<string, unknown>>, 'status'),
|
||||
},
|
||||
},
|
||||
industrial_landscape: {
|
||||
title: 'Thị trường KCN',
|
||||
content: industrialLandscape,
|
||||
},
|
||||
risk_assessment: {
|
||||
title: 'Đánh giá rủi ro',
|
||||
content: riskAssessment,
|
||||
},
|
||||
recommendation: {
|
||||
title: 'Khuyến nghị đầu tư',
|
||||
content: recommendation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateResidentialMarketReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const city = params['city'] as string;
|
||||
const period = params['period'] as string;
|
||||
|
||||
const macroData = await this.macroData.getByProvince(city, ['gdp', 'cpi', 'mortgage_rate', 'fdi', 'population']);
|
||||
|
||||
const macroByIndicator: Record<string, Array<{ period: string; value: number; unit: string }>> = {};
|
||||
for (const dp of macroData) {
|
||||
const arr = macroByIndicator[dp.indicator] ??= [];
|
||||
arr.push({ period: dp.period, value: dp.value, unit: dp.unit });
|
||||
}
|
||||
|
||||
const dataContext = { city, period, macroByIndicator };
|
||||
|
||||
const [executiveSummary, marketOverview, priceAnalysis, supplyDemand, forecast, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt thị trường',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'market_overview',
|
||||
sectionTitle: 'Tổng quan',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'price_analysis',
|
||||
sectionTitle: 'Phân tích giá',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'supply_demand',
|
||||
sectionTitle: 'Cung cầu',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'forecast',
|
||||
sectionTitle: 'Dự báo',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
city,
|
||||
period,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt thị trường',
|
||||
content: executiveSummary,
|
||||
},
|
||||
market_overview: {
|
||||
title: 'Tổng quan',
|
||||
content: marketOverview,
|
||||
},
|
||||
price_analysis: {
|
||||
title: 'Phân tích giá',
|
||||
content: priceAnalysis,
|
||||
},
|
||||
supply_demand: {
|
||||
title: 'Cung cầu',
|
||||
content: supplyDemand,
|
||||
},
|
||||
macro_impact: {
|
||||
title: 'Tác động vĩ mô',
|
||||
data: macroByIndicator,
|
||||
},
|
||||
forecast: {
|
||||
title: 'Dự báo',
|
||||
content: forecast,
|
||||
},
|
||||
recommendation: {
|
||||
title: 'Khuyến nghị',
|
||||
content: recommendation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateDistrictAnalysisReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const city = params['city'] as string;
|
||||
const district = params['district'] as string;
|
||||
|
||||
const dataContext = { city, district };
|
||||
|
||||
const [executiveSummary, marketData, neighborhood, infrastructure, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'market_overview',
|
||||
sectionTitle: 'Dữ liệu thị trường',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'supply_demand',
|
||||
sectionTitle: 'Khu vực lân cận',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'industrial_landscape',
|
||||
sectionTitle: 'Hạ tầng',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
city,
|
||||
district,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: executiveSummary,
|
||||
},
|
||||
market_data: { title: 'Dữ liệu thị trường', content: marketData },
|
||||
neighborhood: { title: 'Khu vực lân cận', content: neighborhood },
|
||||
infrastructure: { title: 'Hạ tầng', content: infrastructure },
|
||||
recommendation: { title: 'Khuyến nghị', content: recommendation },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateGenericReport(
|
||||
type: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const summary = await this.aiNarrative.generateNarrative({
|
||||
reportType: type,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: params,
|
||||
});
|
||||
|
||||
return {
|
||||
reportType: type,
|
||||
params,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: summary,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private groupBy(items: Array<{ [key: string]: unknown }>, key: string): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const val = String(item[key]);
|
||||
result[val] = (result[val] ?? 0) + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth';
|
||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||
import { DeleteReportCommand } from '../../application/commands/delete-report/delete-report.command';
|
||||
import { GenerateReportCommand } from '../../application/commands/generate-report/generate-report.command';
|
||||
import { type GenerateReportResult } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { GetReportQuery } from '../../application/queries/get-report/get-report.query';
|
||||
import { type ListReportsResult } from '../../application/queries/list-reports/list-reports.handler';
|
||||
import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query';
|
||||
import { type ReportEntity } from '../../domain/entities/report.entity';
|
||||
import { type GenerateReportDto } from '../dto/generate-report.dto';
|
||||
import { type ListReportsDto } from '../dto/list-reports.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { sub: string };
|
||||
}
|
||||
|
||||
@ApiTags('reports')
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@Post('generate')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('reports_generated')
|
||||
@ApiOperation({ summary: 'Tạo báo cáo AI (async)' })
|
||||
@ApiResponse({ status: 201, description: 'Report generation started' })
|
||||
async generate(
|
||||
@Body() dto: GenerateReportDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<GenerateReportResult> {
|
||||
return this.commandBus.execute(
|
||||
new GenerateReportCommand(req.user.sub, dto.type, dto.title, dto.params),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Danh sách báo cáo của tôi' })
|
||||
@ApiResponse({ status: 200, description: 'List of reports' })
|
||||
async list(
|
||||
@Query() dto: ListReportsDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result: ListReportsResult = await this.queryBus.execute(
|
||||
new ListReportsQuery(req.user.sub, dto.type, dto.limit, dto.offset),
|
||||
);
|
||||
return {
|
||||
data: result.reports.map((r) => this.toResponse(r)),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Chi tiết báo cáo' })
|
||||
@ApiResponse({ status: 200, description: 'Report details' })
|
||||
async getById(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const report: ReportEntity = await this.queryBus.execute(
|
||||
new GetReportQuery(id, req.user.sub),
|
||||
);
|
||||
return this.toResponse(report);
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Trạng thái tạo báo cáo' })
|
||||
@ApiResponse({ status: 200, description: 'Report status' })
|
||||
async getStatus(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const report: ReportEntity = await this.queryBus.execute(
|
||||
new GetReportQuery(id, req.user.sub),
|
||||
);
|
||||
return {
|
||||
id: report.id,
|
||||
status: report.status,
|
||||
errorMsg: report.errorMsg,
|
||||
pdfUrl: report.pdfUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Xóa báo cáo' })
|
||||
@ApiResponse({ status: 204, description: 'Report deleted' })
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<void> {
|
||||
await this.commandBus.execute(new DeleteReportCommand(id, req.user.sub));
|
||||
}
|
||||
|
||||
private toResponse(report: ReportEntity) {
|
||||
return {
|
||||
id: report.id,
|
||||
type: report.type,
|
||||
title: report.title,
|
||||
params: report.params,
|
||||
content: report.content,
|
||||
pdfUrl: report.pdfUrl,
|
||||
status: report.status,
|
||||
errorMsg: report.errorMsg,
|
||||
createdAt: report.createdAt.toISOString(),
|
||||
updatedAt: report.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsEnum, IsNotEmpty, IsObject, IsString } from 'class-validator';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
|
||||
export class GenerateReportDto {
|
||||
@IsEnum(ReportType)
|
||||
type!: ReportType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsObject()
|
||||
params!: Record<string, unknown>;
|
||||
}
|
||||
2
apps/api/src/modules/reports/presentation/dto/index.ts
Normal file
2
apps/api/src/modules/reports/presentation/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GenerateReportDto } from './generate-report.dto';
|
||||
export { ListReportsDto } from './list-reports.dto';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
|
||||
export class ListReportsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ReportType)
|
||||
type?: ReportType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number = 0;
|
||||
}
|
||||
63
apps/api/src/modules/reports/reports.module.ts
Normal file
63
apps/api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { DeleteReportHandler } from './application/commands/delete-report/delete-report.handler';
|
||||
import { GenerateReportHandler, REPORT_GENERATION_QUEUE } from './application/commands/generate-report/generate-report.handler';
|
||||
import { GetReportHandler } from './application/queries/get-report/get-report.handler';
|
||||
import { ListReportsHandler } from './application/queries/list-reports/list-reports.handler';
|
||||
import { REPORT_REPOSITORY } from './domain/repositories/report.repository';
|
||||
import { AI_NARRATIVE_SERVICE } from './domain/services/ai-narrative.service';
|
||||
import { INFRASTRUCTURE_DATA_SERVICE } from './domain/services/infrastructure-data.service';
|
||||
import { MACRO_DATA_SERVICE } from './domain/services/macro-data.service';
|
||||
import { PDF_GENERATOR_SERVICE } from './domain/services/pdf-generator.service';
|
||||
import { PDF_STORAGE_SERVICE } from './domain/services/pdf-storage.service';
|
||||
import { PrismaReportRepository } from './infrastructure/repositories/prisma-report.repository';
|
||||
import { ClaudeNarrativeService } from './infrastructure/services/claude-narrative.service';
|
||||
import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service';
|
||||
import { PrismaMacroDataService } from './infrastructure/services/macro-data.service';
|
||||
import { MinioPdfStorageService } from './infrastructure/services/minio-pdf-storage.service';
|
||||
import { PuppeteerPdfService } from './infrastructure/services/puppeteer-pdf.service';
|
||||
import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor';
|
||||
import { ReportsController } from './presentation/controllers/reports.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
GenerateReportHandler,
|
||||
DeleteReportHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetReportHandler,
|
||||
ListReportsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CqrsModule,
|
||||
ConfigModule,
|
||||
BullModule.registerQueue({
|
||||
name: REPORT_GENERATION_QUEUE,
|
||||
}),
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: REPORT_REPOSITORY, useClass: PrismaReportRepository },
|
||||
|
||||
// Services
|
||||
{ provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService },
|
||||
{ provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService },
|
||||
{ provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService },
|
||||
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfService },
|
||||
{ provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService },
|
||||
|
||||
// BullMQ processor
|
||||
ReportGenerationProcessor,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [REPORT_REPOSITORY, MACRO_DATA_SERVICE, INFRASTRUCTURE_DATA_SERVICE],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { TransferCategory, TransferCondition, TransferPricingSource } from '@prisma/client';
|
||||
|
||||
export interface CreateTransferItemInput {
|
||||
name: string;
|
||||
brand?: string;
|
||||
modelName?: string;
|
||||
category: TransferCategory;
|
||||
condition: TransferCondition;
|
||||
purchaseYear?: number;
|
||||
originalPriceVND?: bigint;
|
||||
askingPriceVND: bigint;
|
||||
quantity?: number;
|
||||
dimensions?: Record<string, unknown>;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateTransferListingCommand {
|
||||
constructor(
|
||||
public readonly sellerId: string,
|
||||
public readonly category: TransferCategory,
|
||||
public readonly title: string,
|
||||
public readonly description: string | null,
|
||||
public readonly address: string,
|
||||
public readonly ward: string | null,
|
||||
public readonly district: string,
|
||||
public readonly city: string,
|
||||
public readonly latitude: number,
|
||||
public readonly longitude: number,
|
||||
public readonly askingPriceVND: bigint,
|
||||
public readonly pricingSource: TransferPricingSource,
|
||||
public readonly isNegotiable: boolean,
|
||||
public readonly areaM2: number | null,
|
||||
public readonly monthlyRentVND: bigint | null,
|
||||
public readonly depositMonths: number | null,
|
||||
public readonly remainingLeaseMo: number | null,
|
||||
public readonly businessType: string | null,
|
||||
public readonly footTraffic: string | null,
|
||||
public readonly contactPhone: string | null,
|
||||
public readonly contactName: string | null,
|
||||
public readonly items: CreateTransferItemInput[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { TransferListingEntity } from '../../../domain/entities/transfer-listing.entity';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { CreateTransferListingCommand } from './create-transfer-listing.command';
|
||||
|
||||
@CommandHandler(CreateTransferListingCommand)
|
||||
export class CreateTransferListingHandler implements ICommandHandler<CreateTransferListingCommand> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateTransferListingCommand): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const now = new Date();
|
||||
const entity = new TransferListingEntity(id, {
|
||||
sellerId: cmd.sellerId,
|
||||
category: cmd.category,
|
||||
status: 'DRAFT',
|
||||
title: cmd.title,
|
||||
description: cmd.description,
|
||||
address: cmd.address,
|
||||
ward: cmd.ward,
|
||||
district: cmd.district,
|
||||
city: cmd.city,
|
||||
latitude: cmd.latitude,
|
||||
longitude: cmd.longitude,
|
||||
askingPriceVND: cmd.askingPriceVND,
|
||||
aiEstimatePriceVND: null,
|
||||
aiConfidence: null,
|
||||
pricingSource: cmd.pricingSource,
|
||||
isNegotiable: cmd.isNegotiable,
|
||||
areaM2: cmd.areaM2,
|
||||
monthlyRentVND: cmd.monthlyRentVND,
|
||||
depositMonths: cmd.depositMonths,
|
||||
remainingLeaseMo: cmd.remainingLeaseMo,
|
||||
businessType: cmd.businessType,
|
||||
footTraffic: cmd.footTraffic,
|
||||
media: null,
|
||||
moderationScore: null,
|
||||
moderationNotes: null,
|
||||
viewCount: 0,
|
||||
saveCount: 0,
|
||||
inquiryCount: 0,
|
||||
contactPhone: cmd.contactPhone,
|
||||
contactName: cmd.contactName,
|
||||
featuredUntil: null,
|
||||
expiresAt: null,
|
||||
publishedAt: null,
|
||||
}, now, now);
|
||||
|
||||
await this.repo.save(entity, cmd.items);
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateTransferListingCommand } from './create-transfer-listing.command';
|
||||
export type { CreateTransferItemInput } from './create-transfer-listing.command';
|
||||
export { CreateTransferListingHandler } from './create-transfer-listing.handler';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { TransferCategory, TransferCondition } from '@prisma/client';
|
||||
|
||||
export interface EstimateItemInput {
|
||||
category: TransferCategory;
|
||||
condition: TransferCondition;
|
||||
originalPriceVND: number; // DTO uses number, we convert to BigInt
|
||||
purchaseYear: number;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
export class EstimateTransferPricesCommand {
|
||||
constructor(
|
||||
public readonly items: EstimateItemInput[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { estimateTransferListingPrices, type FurniturePricingInput } from '../../../domain/services/furniture-pricing.service';
|
||||
import { EstimateTransferPricesCommand } from './estimate-transfer-prices.command';
|
||||
|
||||
@CommandHandler(EstimateTransferPricesCommand)
|
||||
export class EstimateTransferPricesHandler implements ICommandHandler<EstimateTransferPricesCommand> {
|
||||
async execute(cmd: EstimateTransferPricesCommand) {
|
||||
const inputs: FurniturePricingInput[] = cmd.items.map((item) => ({
|
||||
category: item.category,
|
||||
condition: item.condition,
|
||||
originalPriceVND: BigInt(Math.round(item.originalPriceVND)),
|
||||
purchaseYear: item.purchaseYear,
|
||||
brand: item.brand,
|
||||
}));
|
||||
|
||||
const { estimates, totalEstimateVND, avgConfidence } = estimateTransferListingPrices(inputs);
|
||||
|
||||
return {
|
||||
estimates: estimates.map((e) => ({
|
||||
estimatedPriceVND: e.estimatedPriceVND.toString(),
|
||||
confidence: Math.round(e.confidence * 100) / 100,
|
||||
factors: e.factors,
|
||||
})),
|
||||
totalEstimateVND: totalEstimateVND.toString(),
|
||||
avgConfidence: Math.round(avgConfidence * 100) / 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EstimateTransferPricesCommand, type EstimateItemInput } from './estimate-transfer-prices.command';
|
||||
export { EstimateTransferPricesHandler } from './estimate-transfer-prices.handler';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './create-transfer-listing';
|
||||
export * from './update-transfer-listing';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UpdateTransferListingCommand } from './update-transfer-listing.command';
|
||||
export { UpdateTransferListingHandler } from './update-transfer-listing.handler';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TransferListingStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateTransferListingCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly title?: string,
|
||||
public readonly description?: string | null,
|
||||
public readonly status?: TransferListingStatus,
|
||||
public readonly askingPriceVND?: bigint,
|
||||
public readonly isNegotiable?: boolean,
|
||||
public readonly areaM2?: number | null,
|
||||
public readonly monthlyRentVND?: bigint | null,
|
||||
public readonly depositMonths?: number | null,
|
||||
public readonly remainingLeaseMo?: number | null,
|
||||
public readonly businessType?: string | null,
|
||||
public readonly footTraffic?: string | null,
|
||||
public readonly contactPhone?: string | null,
|
||||
public readonly contactName?: string | null,
|
||||
public readonly media?: Record<string, unknown>[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { UpdateTransferListingCommand } from './update-transfer-listing.command';
|
||||
|
||||
@CommandHandler(UpdateTransferListingCommand)
|
||||
export class UpdateTransferListingHandler implements ICommandHandler<UpdateTransferListingCommand> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: UpdateTransferListingCommand): Promise<{ id: string }> {
|
||||
const entity = await this.repo.findById(cmd.id);
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Transfer listing', cmd.id);
|
||||
}
|
||||
|
||||
entity.updateDetails({
|
||||
title: cmd.title,
|
||||
description: cmd.description,
|
||||
status: cmd.status,
|
||||
askingPriceVND: cmd.askingPriceVND,
|
||||
isNegotiable: cmd.isNegotiable,
|
||||
areaM2: cmd.areaM2,
|
||||
monthlyRentVND: cmd.monthlyRentVND,
|
||||
depositMonths: cmd.depositMonths,
|
||||
remainingLeaseMo: cmd.remainingLeaseMo,
|
||||
businessType: cmd.businessType,
|
||||
footTraffic: cmd.footTraffic,
|
||||
contactPhone: cmd.contactPhone,
|
||||
contactName: cmd.contactName,
|
||||
media: cmd.media,
|
||||
});
|
||||
|
||||
await this.repo.update(entity);
|
||||
return { id: cmd.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { GetTransferListingQuery } from './get-transfer-listing.query';
|
||||
|
||||
@QueryHandler(GetTransferListingQuery)
|
||||
export class GetTransferListingHandler implements IQueryHandler<GetTransferListingQuery> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetTransferListingQuery) {
|
||||
return this.repo.findDetailById(query.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetTransferListingQuery {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GetTransferListingQuery } from './get-transfer-listing.query';
|
||||
export { GetTransferListingHandler } from './get-transfer-listing.handler';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './list-transfer-listings';
|
||||
export * from './get-transfer-listing';
|
||||
export * from './transfer-stats';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ListTransferListingsQuery } from './list-transfer-listings.query';
|
||||
export { ListTransferListingsHandler } from './list-transfer-listings.handler';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { ListTransferListingsQuery } from './list-transfer-listings.query';
|
||||
|
||||
@QueryHandler(ListTransferListingsQuery)
|
||||
export class ListTransferListingsHandler implements IQueryHandler<ListTransferListingsQuery> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListTransferListingsQuery) {
|
||||
return this.repo.search({
|
||||
query: query.query,
|
||||
category: query.category,
|
||||
status: query.status,
|
||||
district: query.district,
|
||||
city: query.city,
|
||||
minPrice: query.minPrice,
|
||||
maxPrice: query.maxPrice,
|
||||
sellerId: query.sellerId,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TransferCategory, TransferListingStatus } from '@prisma/client';
|
||||
|
||||
export class ListTransferListingsQuery {
|
||||
constructor(
|
||||
public readonly query?: string,
|
||||
public readonly category?: TransferCategory,
|
||||
public readonly status?: TransferListingStatus,
|
||||
public readonly district?: string,
|
||||
public readonly city?: string,
|
||||
public readonly minPrice?: number,
|
||||
public readonly maxPrice?: number,
|
||||
public readonly sellerId?: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TransferStatsQuery } from './transfer-stats.query';
|
||||
export { TransferStatsHandler } from './transfer-stats.handler';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { TransferStatsQuery } from './transfer-stats.query';
|
||||
|
||||
@QueryHandler(TransferStatsQuery)
|
||||
export class TransferStatsHandler implements IQueryHandler<TransferStatsQuery> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(_query: TransferStatsQuery) {
|
||||
return this.repo.getStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class TransferStatsQuery {}
|
||||
1
apps/api/src/modules/transfer/domain/entities/index.ts
Normal file
1
apps/api/src/modules/transfer/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TransferListingEntity, type TransferListingProps } from './transfer-listing.entity';
|
||||
@@ -0,0 +1,182 @@
|
||||
import { type TransferCategory, type TransferListingStatus, type TransferPricingSource } from '@prisma/client';
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
|
||||
export interface TransferListingProps {
|
||||
sellerId: string;
|
||||
category: TransferCategory;
|
||||
status: TransferListingStatus;
|
||||
title: string;
|
||||
description: string | null;
|
||||
address: string;
|
||||
ward: string | null;
|
||||
district: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
askingPriceVND: bigint;
|
||||
aiEstimatePriceVND: bigint | null;
|
||||
aiConfidence: number | null;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
areaM2: number | null;
|
||||
monthlyRentVND: bigint | null;
|
||||
depositMonths: number | null;
|
||||
remainingLeaseMo: number | null;
|
||||
businessType: string | null;
|
||||
footTraffic: string | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
moderationScore: number | null;
|
||||
moderationNotes: string | null;
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
inquiryCount: number;
|
||||
contactPhone: string | null;
|
||||
contactName: string | null;
|
||||
featuredUntil: Date | null;
|
||||
expiresAt: Date | null;
|
||||
publishedAt: Date | null;
|
||||
}
|
||||
|
||||
export class TransferListingEntity extends AggregateRoot<string> {
|
||||
private _sellerId: string;
|
||||
private _category: TransferCategory;
|
||||
private _status: TransferListingStatus;
|
||||
private _title: string;
|
||||
private _description: string | null;
|
||||
private _address: string;
|
||||
private _ward: string | null;
|
||||
private _district: string;
|
||||
private _city: string;
|
||||
private _latitude: number;
|
||||
private _longitude: number;
|
||||
private _askingPriceVND: bigint;
|
||||
private _aiEstimatePriceVND: bigint | null;
|
||||
private _aiConfidence: number | null;
|
||||
private _pricingSource: TransferPricingSource;
|
||||
private _isNegotiable: boolean;
|
||||
private _areaM2: number | null;
|
||||
private _monthlyRentVND: bigint | null;
|
||||
private _depositMonths: number | null;
|
||||
private _remainingLeaseMo: number | null;
|
||||
private _businessType: string | null;
|
||||
private _footTraffic: string | null;
|
||||
private _media: Record<string, unknown>[] | null;
|
||||
private _moderationScore: number | null;
|
||||
private _moderationNotes: string | null;
|
||||
private _viewCount: number;
|
||||
private _saveCount: number;
|
||||
private _inquiryCount: number;
|
||||
private _contactPhone: string | null;
|
||||
private _contactName: string | null;
|
||||
private _featuredUntil: Date | null;
|
||||
private _expiresAt: Date | null;
|
||||
private _publishedAt: Date | null;
|
||||
|
||||
constructor(id: string, props: TransferListingProps, createdAt: Date, updatedAt: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._sellerId = props.sellerId;
|
||||
this._category = props.category;
|
||||
this._status = props.status;
|
||||
this._title = props.title;
|
||||
this._description = props.description;
|
||||
this._address = props.address;
|
||||
this._ward = props.ward;
|
||||
this._district = props.district;
|
||||
this._city = props.city;
|
||||
this._latitude = props.latitude;
|
||||
this._longitude = props.longitude;
|
||||
this._askingPriceVND = props.askingPriceVND;
|
||||
this._aiEstimatePriceVND = props.aiEstimatePriceVND;
|
||||
this._aiConfidence = props.aiConfidence;
|
||||
this._pricingSource = props.pricingSource;
|
||||
this._isNegotiable = props.isNegotiable;
|
||||
this._areaM2 = props.areaM2;
|
||||
this._monthlyRentVND = props.monthlyRentVND;
|
||||
this._depositMonths = props.depositMonths;
|
||||
this._remainingLeaseMo = props.remainingLeaseMo;
|
||||
this._businessType = props.businessType;
|
||||
this._footTraffic = props.footTraffic;
|
||||
this._media = props.media;
|
||||
this._moderationScore = props.moderationScore;
|
||||
this._moderationNotes = props.moderationNotes;
|
||||
this._viewCount = props.viewCount;
|
||||
this._saveCount = props.saveCount;
|
||||
this._inquiryCount = props.inquiryCount;
|
||||
this._contactPhone = props.contactPhone;
|
||||
this._contactName = props.contactName;
|
||||
this._featuredUntil = props.featuredUntil;
|
||||
this._expiresAt = props.expiresAt;
|
||||
this._publishedAt = props.publishedAt;
|
||||
}
|
||||
|
||||
get sellerId() { return this._sellerId; }
|
||||
get category() { return this._category; }
|
||||
get status() { return this._status; }
|
||||
get title() { return this._title; }
|
||||
get description() { return this._description; }
|
||||
get address() { return this._address; }
|
||||
get ward() { return this._ward; }
|
||||
get district() { return this._district; }
|
||||
get city() { return this._city; }
|
||||
get latitude() { return this._latitude; }
|
||||
get longitude() { return this._longitude; }
|
||||
get askingPriceVND() { return this._askingPriceVND; }
|
||||
get aiEstimatePriceVND() { return this._aiEstimatePriceVND; }
|
||||
get aiConfidence() { return this._aiConfidence; }
|
||||
get pricingSource() { return this._pricingSource; }
|
||||
get isNegotiable() { return this._isNegotiable; }
|
||||
get areaM2() { return this._areaM2; }
|
||||
get monthlyRentVND() { return this._monthlyRentVND; }
|
||||
get depositMonths() { return this._depositMonths; }
|
||||
get remainingLeaseMo() { return this._remainingLeaseMo; }
|
||||
get businessType() { return this._businessType; }
|
||||
get footTraffic() { return this._footTraffic; }
|
||||
get media() { return this._media; }
|
||||
get moderationScore() { return this._moderationScore; }
|
||||
get moderationNotes() { return this._moderationNotes; }
|
||||
get viewCount() { return this._viewCount; }
|
||||
get saveCount() { return this._saveCount; }
|
||||
get inquiryCount() { return this._inquiryCount; }
|
||||
get contactPhone() { return this._contactPhone; }
|
||||
get contactName() { return this._contactName; }
|
||||
get featuredUntil() { return this._featuredUntil; }
|
||||
get expiresAt() { return this._expiresAt; }
|
||||
get publishedAt() { return this._publishedAt; }
|
||||
|
||||
updateDetails(props: Partial<TransferListingProps>): void {
|
||||
if (props.sellerId !== undefined) this._sellerId = props.sellerId;
|
||||
if (props.category !== undefined) this._category = props.category;
|
||||
if (props.status !== undefined) this._status = props.status;
|
||||
if (props.title !== undefined) this._title = props.title;
|
||||
if (props.description !== undefined) this._description = props.description;
|
||||
if (props.address !== undefined) this._address = props.address;
|
||||
if (props.ward !== undefined) this._ward = props.ward;
|
||||
if (props.district !== undefined) this._district = props.district;
|
||||
if (props.city !== undefined) this._city = props.city;
|
||||
if (props.latitude !== undefined) this._latitude = props.latitude;
|
||||
if (props.longitude !== undefined) this._longitude = props.longitude;
|
||||
if (props.askingPriceVND !== undefined) this._askingPriceVND = props.askingPriceVND;
|
||||
if (props.aiEstimatePriceVND !== undefined) this._aiEstimatePriceVND = props.aiEstimatePriceVND;
|
||||
if (props.aiConfidence !== undefined) this._aiConfidence = props.aiConfidence;
|
||||
if (props.pricingSource !== undefined) this._pricingSource = props.pricingSource;
|
||||
if (props.isNegotiable !== undefined) this._isNegotiable = props.isNegotiable;
|
||||
if (props.areaM2 !== undefined) this._areaM2 = props.areaM2;
|
||||
if (props.monthlyRentVND !== undefined) this._monthlyRentVND = props.monthlyRentVND;
|
||||
if (props.depositMonths !== undefined) this._depositMonths = props.depositMonths;
|
||||
if (props.remainingLeaseMo !== undefined) this._remainingLeaseMo = props.remainingLeaseMo;
|
||||
if (props.businessType !== undefined) this._businessType = props.businessType;
|
||||
if (props.footTraffic !== undefined) this._footTraffic = props.footTraffic;
|
||||
if (props.media !== undefined) this._media = props.media;
|
||||
if (props.moderationScore !== undefined) this._moderationScore = props.moderationScore;
|
||||
if (props.moderationNotes !== undefined) this._moderationNotes = props.moderationNotes;
|
||||
if (props.viewCount !== undefined) this._viewCount = props.viewCount;
|
||||
if (props.saveCount !== undefined) this._saveCount = props.saveCount;
|
||||
if (props.inquiryCount !== undefined) this._inquiryCount = props.inquiryCount;
|
||||
if (props.contactPhone !== undefined) this._contactPhone = props.contactPhone;
|
||||
if (props.contactName !== undefined) this._contactName = props.contactName;
|
||||
if (props.featuredUntil !== undefined) this._featuredUntil = props.featuredUntil;
|
||||
if (props.expiresAt !== undefined) this._expiresAt = props.expiresAt;
|
||||
if (props.publishedAt !== undefined) this._publishedAt = props.publishedAt;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
1
apps/api/src/modules/transfer/domain/events/index.ts
Normal file
1
apps/api/src/modules/transfer/domain/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
10
apps/api/src/modules/transfer/domain/repositories/index.ts
Normal file
10
apps/api/src/modules/transfer/domain/repositories/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type TransferListingSearchParams,
|
||||
type PaginatedResult,
|
||||
type TransferListingListItem,
|
||||
type TransferItemData,
|
||||
type TransferListingDetailData,
|
||||
type TransferStatsData,
|
||||
type ITransferListingRepository,
|
||||
} from './transfer-listing.repository';
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { TransferCategory, TransferCondition, TransferListingStatus, TransferPricingSource } from '@prisma/client';
|
||||
import type { CreateTransferItemInput } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
||||
import type { TransferListingEntity } from '../entities/transfer-listing.entity';
|
||||
|
||||
export const TRANSFER_LISTING_REPOSITORY = Symbol('TRANSFER_LISTING_REPOSITORY');
|
||||
|
||||
export interface TransferListingSearchParams {
|
||||
query?: string;
|
||||
category?: TransferCategory;
|
||||
status?: TransferListingStatus;
|
||||
district?: string;
|
||||
city?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
sellerId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface TransferListingListItem {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
category: TransferCategory;
|
||||
status: TransferListingStatus;
|
||||
title: string;
|
||||
address: string;
|
||||
district: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
askingPriceVND: bigint;
|
||||
aiEstimatePriceVND: bigint | null;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
areaM2: number | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
viewCount: number;
|
||||
inquiryCount: number;
|
||||
publishedAt: Date | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface TransferItemData {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
modelName: string | null;
|
||||
category: TransferCategory;
|
||||
condition: TransferCondition;
|
||||
purchaseYear: number | null;
|
||||
originalPriceVND: bigint | null;
|
||||
askingPriceVND: bigint;
|
||||
aiEstimatePriceVND: bigint | null;
|
||||
aiConfidence: number | null;
|
||||
quantity: number;
|
||||
dimensions: Record<string, unknown> | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface TransferListingDetailData {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
category: TransferCategory;
|
||||
status: TransferListingStatus;
|
||||
title: string;
|
||||
description: string | null;
|
||||
address: string;
|
||||
ward: string | null;
|
||||
district: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
askingPriceVND: bigint;
|
||||
aiEstimatePriceVND: bigint | null;
|
||||
aiConfidence: number | null;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
areaM2: number | null;
|
||||
monthlyRentVND: bigint | null;
|
||||
depositMonths: number | null;
|
||||
remainingLeaseMo: number | null;
|
||||
businessType: string | null;
|
||||
footTraffic: string | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
moderationScore: number | null;
|
||||
moderationNotes: string | null;
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
inquiryCount: number;
|
||||
contactPhone: string | null;
|
||||
contactName: string | null;
|
||||
featuredUntil: Date | null;
|
||||
expiresAt: Date | null;
|
||||
publishedAt: Date | null;
|
||||
items: TransferItemData[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TransferStatsData {
|
||||
totalListings: number;
|
||||
totalValue: bigint;
|
||||
byCategory: { category: string; count: number; avgPrice: number }[];
|
||||
byDistrict: { district: string; count: number; avgPrice: number }[];
|
||||
byStatus: { status: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface ITransferListingRepository {
|
||||
findById(id: string): Promise<TransferListingEntity | null>;
|
||||
findDetailById(id: string): Promise<TransferListingDetailData | null>;
|
||||
save(entity: TransferListingEntity, items: CreateTransferItemInput[]): Promise<void>;
|
||||
update(entity: TransferListingEntity): Promise<void>;
|
||||
search(params: TransferListingSearchParams): Promise<PaginatedResult<TransferListingListItem>>;
|
||||
getStats(): Promise<TransferStatsData>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { TransferCategory, TransferCondition } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Depreciation curves by category (annual depreciation rate as fraction).
|
||||
* Based on Vietnamese secondhand market data:
|
||||
* - Furniture depreciates ~15-20% per year
|
||||
* - Appliances ~20-25% (technology obsolescence)
|
||||
* - Office equipment ~25-30%
|
||||
* - Kitchen equipment ~15-20%
|
||||
*/
|
||||
const DEPRECIATION_RATES: Record<string, number> = {
|
||||
FURNITURE: 0.18,
|
||||
APPLIANCE: 0.22,
|
||||
OFFICE_EQUIPMENT: 0.27,
|
||||
KITCHEN: 0.18,
|
||||
PREMISES: 0, // Premises don't depreciate the same way
|
||||
FULL_UNIT: 0.15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Condition multiplier — adjusts the depreciation-based estimate.
|
||||
* NEW and LIKE_NEW items retain more value; WORN items lose extra.
|
||||
*/
|
||||
const CONDITION_MULTIPLIERS: Record<string, number> = {
|
||||
NEW: 1.0,
|
||||
LIKE_NEW: 0.92,
|
||||
GOOD: 0.80,
|
||||
FAIR: 0.65,
|
||||
WORN: 0.45,
|
||||
};
|
||||
|
||||
/**
|
||||
* Brand tier multipliers — premium brands retain value better.
|
||||
* Keys are lowercase brand names (partial match).
|
||||
*/
|
||||
const BRAND_TIERS: { keywords: string[]; multiplier: number }[] = [
|
||||
// Premium tier — 1.3x value retention
|
||||
{
|
||||
keywords: [
|
||||
'herman miller', 'steelcase', 'b&b italia', 'poliform', 'molteni',
|
||||
'boffi', 'sub-zero', 'wolf', 'miele', 'gaggenau', 'smeg',
|
||||
'dyson', 'bang & olufsen', 'lg signature',
|
||||
],
|
||||
multiplier: 1.30,
|
||||
},
|
||||
// Mid-premium tier — 1.15x
|
||||
{
|
||||
keywords: [
|
||||
'ikea', 'muji', 'ashley', 'pottery barn', 'west elm',
|
||||
'samsung', 'lg', 'panasonic', 'bosch', 'electrolux',
|
||||
'daikin', 'mitsubishi', 'toshiba', 'hitachi',
|
||||
'hòa phát', 'xuân hòa',
|
||||
],
|
||||
multiplier: 1.15,
|
||||
},
|
||||
// Standard tier — 1.0x (default, no adjustment)
|
||||
];
|
||||
|
||||
/** Minimum floor price: 10% of original (items always have some scrap/resale value) */
|
||||
const MIN_VALUE_RATIO = 0.10;
|
||||
|
||||
/** Maximum age for depreciation curve (beyond this, use floor price) */
|
||||
const MAX_DEPRECIATION_YEARS = 10;
|
||||
|
||||
export interface FurniturePriceEstimate {
|
||||
estimatedPriceVND: bigint;
|
||||
confidence: number; // 0-1
|
||||
factors: {
|
||||
depreciationRate: number;
|
||||
ageYears: number;
|
||||
conditionMultiplier: number;
|
||||
brandMultiplier: number;
|
||||
depreciatedValue: number; // as fraction of original
|
||||
};
|
||||
}
|
||||
|
||||
export interface FurniturePricingInput {
|
||||
category: TransferCategory;
|
||||
condition: TransferCondition;
|
||||
originalPriceVND: bigint;
|
||||
purchaseYear: number;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the current market value of a used furniture/appliance item.
|
||||
*
|
||||
* Formula: estimatedPrice = originalPrice × depreciatedValue × conditionMultiplier × brandMultiplier
|
||||
* Where depreciatedValue = max(MIN_VALUE_RATIO, (1 - annualRate) ^ ageYears)
|
||||
*/
|
||||
export function estimateFurniturePrice(input: FurniturePricingInput): FurniturePriceEstimate {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const ageYears = Math.max(0, currentYear - input.purchaseYear);
|
||||
|
||||
const annualRate = DEPRECIATION_RATES[input.category] ?? 0.20;
|
||||
const conditionMult = CONDITION_MULTIPLIERS[input.condition] ?? 0.70;
|
||||
const brandMult = getBrandMultiplier(input.brand);
|
||||
|
||||
// Exponential depreciation with floor
|
||||
const rawDepreciated = Math.pow(1 - annualRate, Math.min(ageYears, MAX_DEPRECIATION_YEARS));
|
||||
const depreciatedValue = Math.max(MIN_VALUE_RATIO, rawDepreciated);
|
||||
|
||||
const finalRatio = depreciatedValue * conditionMult * brandMult;
|
||||
const originalNum = Number(input.originalPriceVND);
|
||||
const estimatedNum = Math.round(originalNum * finalRatio);
|
||||
|
||||
// Confidence based on data quality
|
||||
let confidence = 0.70; // base confidence
|
||||
if (input.brand) confidence += 0.10; // known brand helps
|
||||
if (input.purchaseYear > 0) confidence += 0.10; // known age helps
|
||||
if (ageYears <= 5) confidence += 0.05; // recent items are more predictable
|
||||
if (ageYears > MAX_DEPRECIATION_YEARS) confidence -= 0.15; // very old = less predictable
|
||||
confidence = Math.max(0.30, Math.min(0.95, confidence));
|
||||
|
||||
return {
|
||||
estimatedPriceVND: BigInt(estimatedNum),
|
||||
confidence,
|
||||
factors: {
|
||||
depreciationRate: annualRate,
|
||||
ageYears,
|
||||
conditionMultiplier: conditionMult,
|
||||
brandMultiplier: brandMult,
|
||||
depreciatedValue: finalRatio,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch estimate prices for multiple items in a transfer listing.
|
||||
*/
|
||||
export function estimateTransferListingPrices(
|
||||
items: FurniturePricingInput[],
|
||||
): { estimates: FurniturePriceEstimate[]; totalEstimateVND: bigint; avgConfidence: number } {
|
||||
const estimates = items.map((item) => estimateFurniturePrice(item));
|
||||
const totalEstimateVND = estimates.reduce(
|
||||
(sum, e) => sum + e.estimatedPriceVND,
|
||||
BigInt(0),
|
||||
);
|
||||
const avgConfidence = estimates.length > 0
|
||||
? estimates.reduce((sum, e) => sum + e.confidence, 0) / estimates.length
|
||||
: 0;
|
||||
|
||||
return { estimates, totalEstimateVND, avgConfidence };
|
||||
}
|
||||
|
||||
function getBrandMultiplier(brand?: string): number {
|
||||
if (!brand) return 1.0;
|
||||
const lower = brand.toLowerCase();
|
||||
for (const tier of BRAND_TIERS) {
|
||||
if (tier.keywords.some((kw) => lower.includes(kw))) {
|
||||
return tier.multiplier;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
6
apps/api/src/modules/transfer/domain/services/index.ts
Normal file
6
apps/api/src/modules/transfer/domain/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
estimateFurniturePrice,
|
||||
estimateTransferListingPrices,
|
||||
type FurniturePriceEstimate,
|
||||
type FurniturePricingInput,
|
||||
} from './furniture-pricing.service';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user