diff --git a/.env.example b/.env.example index 46f95a1..4c820ec 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index 96a7773..12e5be5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4f3bb93..98a977c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index ca1a246..12906e4 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -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'; diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index 9d479c8..0a3a0c3 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -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'; diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts new file mode 100644 index 0000000..57e1b18 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts @@ -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 | null, + public readonly connectivity: Record | null, + public readonly incentives: Record | null, + public readonly targetIndustries: string[], + public readonly description: string | null, + public readonly descriptionEn: string | null, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts new file mode 100644 index 0000000..9bca462 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.command.ts b/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.command.ts new file mode 100644 index 0000000..4176b32 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.command.ts @@ -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 | null, + public readonly connectivity?: Record | null, + public readonly incentives?: Record | null, + public readonly targetIndustries?: string[], + public readonly description?: string | null, + public readonly descriptionEn?: string | null, + public readonly isVerified?: boolean, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.handler.ts b/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.handler.ts new file mode 100644 index 0000000..6898e1d --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/update-industrial-park/update-industrial-park.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(cmd: UpdateIndustrialParkCommand): Promise { + 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); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.handler.ts b/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.handler.ts new file mode 100644 index 0000000..e9e20f0 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(query: CompareIndustrialParksQuery): Promise { + if (query.ids.length < 2 || query.ids.length > 5) { + throw new ValidationException('Compare requires 2-5 park IDs'); + } + return this.repo.compareParks(query.ids); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.query.ts b/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.query.ts new file mode 100644 index 0000000..09cc895 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/compare-industrial-parks/compare-industrial-parks.query.ts @@ -0,0 +1,5 @@ +export class CompareIndustrialParksQuery { + constructor( + public readonly ids: string[], + ) {} +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.handler.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.handler.ts new file mode 100644 index 0000000..e8bdd61 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(query: GetIndustrialParkQuery): Promise { + // Try slug first, then ID + const result = await this.repo.findDetailBySlug(query.slugOrId); + if (result) return result; + return this.repo.findDetailById(query.slugOrId); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.query.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.query.ts new file mode 100644 index 0000000..d3835ab --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-park/get-industrial-park.query.ts @@ -0,0 +1,5 @@ +export class GetIndustrialParkQuery { + constructor( + public readonly slugOrId: string, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.handler.ts b/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.handler.ts new file mode 100644 index 0000000..c2fa1f0 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(_query: IndustrialMarketQuery): Promise { + return this.repo.getMarketData(); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.query.ts b/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.query.ts new file mode 100644 index 0000000..bb30a9f --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/industrial-market/industrial-market.query.ts @@ -0,0 +1 @@ +export class IndustrialMarketQuery {} diff --git a/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.handler.ts b/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.handler.ts new file mode 100644 index 0000000..46f469f --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(_query: IndustrialParkStatsQuery): Promise { + return this.repo.getStats(); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.query.ts b/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.query.ts new file mode 100644 index 0000000..b5a100c --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/industrial-park-stats/industrial-park-stats.query.ts @@ -0,0 +1 @@ +export class IndustrialParkStatsQuery {} diff --git a/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.handler.ts b/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.handler.ts new file mode 100644 index 0000000..8b97ffa --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.handler.ts @@ -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 { + constructor( + @Inject(INDUSTRIAL_PARK_REPOSITORY) + private readonly repo: IIndustrialParkRepository, + ) {} + + async execute(query: ListIndustrialParksQuery): Promise> { + 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, + }); + } +} diff --git a/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.query.ts b/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.query.ts new file mode 100644 index 0000000..5fe8c2a --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-industrial-parks/list-industrial-parks.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/industrial/domain/entities/industrial-park.entity.ts b/apps/api/src/modules/industrial/domain/entities/industrial-park.entity.ts new file mode 100644 index 0000000..3052dd0 --- /dev/null +++ b/apps/api/src/modules/industrial/domain/entities/industrial-park.entity.ts @@ -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 | null; + connectivity: Record | null; + incentives: Record | null; + targetIndustries: string[]; + existingTenants: Record[] | null; + certifications: string[] | null; + media: Record[] | null; + documents: Record[] | null; + description: string | null; + descriptionEn: string | null; + isVerified: boolean; +} + +export class IndustrialParkEntity extends AggregateRoot { + 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 | null; + private _connectivity: Record | null; + private _incentives: Record | null; + private _targetIndustries: string[]; + private _existingTenants: Record[] | null; + private _certifications: string[] | null; + private _media: Record[] | null; + private _documents: Record[] | 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): 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(); + } +} diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts new file mode 100644 index 0000000..e45fc09 --- /dev/null +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts @@ -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 { + 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 | null; + connectivity: Record | null; + incentives: Record | null; + targetIndustries: string[]; + existingTenants: Record[] | null; + certifications: string[] | null; + media: Record[] | null; + documents: Record[] | 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; + findBySlug(slug: string): Promise; + findDetailBySlug(slug: string): Promise; + findDetailById(id: string): Promise; + save(entity: IndustrialParkEntity): Promise; + update(entity: IndustrialParkEntity): Promise; + search(params: IndustrialParkSearchParams): Promise>; + compareParks(ids: string[]): Promise; + getStats(): Promise; + getMarketData(): Promise; +} diff --git a/apps/api/src/modules/industrial/index.ts b/apps/api/src/modules/industrial/index.ts new file mode 100644 index 0000000..65ce2b2 --- /dev/null +++ b/apps/api/src/modules/industrial/index.ts @@ -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'; diff --git a/apps/api/src/modules/industrial/industrial.module.ts b/apps/api/src/modules/industrial/industrial.module.ts new file mode 100644 index 0000000..153cd5d --- /dev/null +++ b/apps/api/src/modules/industrial/industrial.module.ts @@ -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 {} diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts new file mode 100644 index 0000000..8166c7b --- /dev/null +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -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 { + const row = await this.prisma.$queryRaw` + 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 { + const row = await this.prisma.$queryRaw` + 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 { + const rows = await this.prisma.$queryRaw` + 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 { + const rows = await this.prisma.$queryRaw` + 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 { + 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 { + 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> { + 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( + `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 { + const rows = await this.prisma.$queryRaw` + 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 { + 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 { + 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 | null, + connectivity: row.connectivity as Record | null, + incentives: row.incentives as Record | null, + targetIndustries: row.targetIndustries ?? [], + existingTenants: row.existingTenants as Record[] | null, + certifications: row.certifications as string[] | null, + media: row.media as Record[] | null, + documents: row.documents as Record[] | 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 | null, + connectivity: row.connectivity as Record | null, + incentives: row.incentives as Record | null, + targetIndustries: row.targetIndustries ?? [], + existingTenants: row.existingTenants as Record[] | null, + certifications: row.certifications as string[] | null, + media: row.media as Record[] | null, + documents: row.documents as Record[] | 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; +} diff --git a/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts b/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts new file mode 100644 index 0000000..1d23d29 --- /dev/null +++ b/apps/api/src/modules/industrial/infrastructure/services/typesense-industrial.service.ts @@ -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 { + 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 { + 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 { + if (!this.client) return; + + const parks = await this.prisma.$queryRaw` + 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 { + if (!this.client) return; + + const [park] = await this.prisma.$queryRaw` + 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); + } +} diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts new file mode 100644 index 0000000..716140a --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -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, + ), + ); + } +} diff --git a/apps/api/src/modules/industrial/presentation/dto/compare-industrial-parks.dto.ts b/apps/api/src/modules/industrial/presentation/dto/compare-industrial-parks.dto.ts new file mode 100644 index 0000000..dd43f05 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/compare-industrial-parks.dto.ts @@ -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[]; +} diff --git a/apps/api/src/modules/industrial/presentation/dto/create-industrial-park.dto.ts b/apps/api/src/modules/industrial/presentation/dto/create-industrial-park.dto.ts new file mode 100644 index 0000000..e319b3f --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/create-industrial-park.dto.ts @@ -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; + + @ApiPropertyOptional({ description: 'Kết nối giao thông' }) + @IsOptional() + @IsObject() + connectivity?: Record; + + @ApiPropertyOptional({ description: 'Ưu đãi đầu tư' }) + @IsOptional() + @IsObject() + incentives?: Record; + + @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; +} diff --git a/apps/api/src/modules/industrial/presentation/dto/search-industrial-parks.dto.ts b/apps/api/src/modules/industrial/presentation/dto/search-industrial-parks.dto.ts new file mode 100644 index 0000000..ced5d67 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/search-industrial-parks.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts b/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts new file mode 100644 index 0000000..be29ee8 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts @@ -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; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + connectivity?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + incentives?: Record; + + @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; +} diff --git a/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts index ba87c20..d52adb3 100644 --- a/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/feature-listing/feature-listing.handler.ts @@ -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, diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts index 3991597..0252312 100644 --- a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts @@ -6,7 +6,6 @@ import { MinLength, IsArray, ValidateNested, - IsNumber, IsInt, Min, } from 'class-validator'; diff --git a/apps/api/src/modules/payments/index.ts b/apps/api/src/modules/payments/index.ts index 3a9ad81..06d8111 100644 --- a/apps/api/src/modules/payments/index.ts +++ b/apps/api/src/modules/payments/index.ts @@ -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'; diff --git a/apps/api/src/modules/reports/application/commands/delete-report/delete-report.command.ts b/apps/api/src/modules/reports/application/commands/delete-report/delete-report.command.ts new file mode 100644 index 0000000..4b77965 --- /dev/null +++ b/apps/api/src/modules/reports/application/commands/delete-report/delete-report.command.ts @@ -0,0 +1,6 @@ +export class DeleteReportCommand { + constructor( + public readonly reportId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/reports/application/commands/delete-report/delete-report.handler.ts b/apps/api/src/modules/reports/application/commands/delete-report/delete-report.handler.ts new file mode 100644 index 0000000..4751cf9 --- /dev/null +++ b/apps/api/src/modules/reports/application/commands/delete-report/delete-report.handler.ts @@ -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 { + constructor( + @Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository, + ) {} + + async execute(command: DeleteReportCommand): Promise { + 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); + } +} diff --git a/apps/api/src/modules/reports/application/commands/generate-report/generate-report.command.ts b/apps/api/src/modules/reports/application/commands/generate-report/generate-report.command.ts new file mode 100644 index 0000000..b626d21 --- /dev/null +++ b/apps/api/src/modules/reports/application/commands/generate-report/generate-report.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reports/application/commands/generate-report/generate-report.handler.ts b/apps/api/src/modules/reports/application/commands/generate-report/generate-report.handler.ts new file mode 100644 index 0000000..8d7a419 --- /dev/null +++ b/apps/api/src/modules/reports/application/commands/generate-report/generate-report.handler.ts @@ -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 { + constructor( + @Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository, + @InjectQueue(REPORT_GENERATION_QUEUE) private readonly reportQueue: Queue, + ) {} + + async execute(command: GenerateReportCommand): Promise { + 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 }; + } +} diff --git a/apps/api/src/modules/reports/application/index.ts b/apps/api/src/modules/reports/application/index.ts new file mode 100644 index 0000000..3375b22 --- /dev/null +++ b/apps/api/src/modules/reports/application/index.ts @@ -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'; diff --git a/apps/api/src/modules/reports/application/queries/get-report/get-report.handler.ts b/apps/api/src/modules/reports/application/queries/get-report/get-report.handler.ts new file mode 100644 index 0000000..851ebd0 --- /dev/null +++ b/apps/api/src/modules/reports/application/queries/get-report/get-report.handler.ts @@ -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 { + constructor( + @Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository, + ) {} + + async execute(query: GetReportQuery): Promise { + 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; + } +} diff --git a/apps/api/src/modules/reports/application/queries/get-report/get-report.query.ts b/apps/api/src/modules/reports/application/queries/get-report/get-report.query.ts new file mode 100644 index 0000000..2f3d2bc --- /dev/null +++ b/apps/api/src/modules/reports/application/queries/get-report/get-report.query.ts @@ -0,0 +1,6 @@ +export class GetReportQuery { + constructor( + public readonly reportId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/reports/application/queries/list-reports/list-reports.handler.ts b/apps/api/src/modules/reports/application/queries/list-reports/list-reports.handler.ts new file mode 100644 index 0000000..7db215a --- /dev/null +++ b/apps/api/src/modules/reports/application/queries/list-reports/list-reports.handler.ts @@ -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 { + constructor( + @Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository, + ) {} + + async execute(query: ListReportsQuery): Promise { + return this.reportRepo.findByUserId({ + userId: query.userId, + type: query.type, + limit: query.limit, + offset: query.offset, + }); + } +} diff --git a/apps/api/src/modules/reports/application/queries/list-reports/list-reports.query.ts b/apps/api/src/modules/reports/application/queries/list-reports/list-reports.query.ts new file mode 100644 index 0000000..cf9b777 --- /dev/null +++ b/apps/api/src/modules/reports/application/queries/list-reports/list-reports.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reports/domain/entities/index.ts b/apps/api/src/modules/reports/domain/entities/index.ts new file mode 100644 index 0000000..24d8a68 --- /dev/null +++ b/apps/api/src/modules/reports/domain/entities/index.ts @@ -0,0 +1 @@ +export { ReportEntity, type ReportProps } from './report.entity'; diff --git a/apps/api/src/modules/reports/domain/entities/report.entity.ts b/apps/api/src/modules/reports/domain/entities/report.entity.ts new file mode 100644 index 0000000..637e6f2 --- /dev/null +++ b/apps/api/src/modules/reports/domain/entities/report.entity.ts @@ -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; + content: Record | null; + pdfUrl: string | null; + status: ReportStatus; + errorMsg: string | null; +} + +export class ReportEntity extends AggregateRoot { + private _userId: string; + private _type: ReportType; + private _title: string; + private _params: Record; + private _content: Record | 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 { return this._params; } + get content(): Record | 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, + ): ReportEntity { + return new ReportEntity(id, { + userId, + type, + title, + params, + content: null, + pdfUrl: null, + status: ReportStatus.GENERATING, + errorMsg: null, + }); + } + + markReady(content: Record, 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(); + } +} diff --git a/apps/api/src/modules/reports/domain/enums/index.ts b/apps/api/src/modules/reports/domain/enums/index.ts new file mode 100644 index 0000000..2205330 --- /dev/null +++ b/apps/api/src/modules/reports/domain/enums/index.ts @@ -0,0 +1,2 @@ +export { ReportType } from './report-type.enum'; +export { ReportStatus } from './report-status.enum'; diff --git a/apps/api/src/modules/reports/domain/enums/report-status.enum.ts b/apps/api/src/modules/reports/domain/enums/report-status.enum.ts new file mode 100644 index 0000000..07031e5 --- /dev/null +++ b/apps/api/src/modules/reports/domain/enums/report-status.enum.ts @@ -0,0 +1,5 @@ +export enum ReportStatus { + GENERATING = 'GENERATING', + READY = 'READY', + FAILED = 'FAILED', +} diff --git a/apps/api/src/modules/reports/domain/enums/report-type.enum.ts b/apps/api/src/modules/reports/domain/enums/report-type.enum.ts new file mode 100644 index 0000000..bfbd14f --- /dev/null +++ b/apps/api/src/modules/reports/domain/enums/report-type.enum.ts @@ -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', +} diff --git a/apps/api/src/modules/reports/domain/index.ts b/apps/api/src/modules/reports/domain/index.ts new file mode 100644 index 0000000..554b8d0 --- /dev/null +++ b/apps/api/src/modules/reports/domain/index.ts @@ -0,0 +1,4 @@ +export * from './entities'; +export * from './enums'; +export * from './repositories'; +export * from './services'; diff --git a/apps/api/src/modules/reports/domain/repositories/index.ts b/apps/api/src/modules/reports/domain/repositories/index.ts new file mode 100644 index 0000000..5ddcee9 --- /dev/null +++ b/apps/api/src/modules/reports/domain/repositories/index.ts @@ -0,0 +1 @@ +export { REPORT_REPOSITORY, type IReportRepository, type ListReportsFilter } from './report.repository'; diff --git a/apps/api/src/modules/reports/domain/repositories/report.repository.ts b/apps/api/src/modules/reports/domain/repositories/report.repository.ts new file mode 100644 index 0000000..177b8d8 --- /dev/null +++ b/apps/api/src/modules/reports/domain/repositories/report.repository.ts @@ -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; + findByUserId(filter: ListReportsFilter): Promise<{ reports: ReportEntity[]; total: number }>; + save(entity: ReportEntity): Promise; + update(entity: ReportEntity): Promise; + delete(id: string): Promise; + countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/ai-narrative.service.ts b/apps/api/src/modules/reports/domain/services/ai-narrative.service.ts new file mode 100644 index 0000000..8800387 --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/ai-narrative.service.ts @@ -0,0 +1,13 @@ +export const AI_NARRATIVE_SERVICE = Symbol('AI_NARRATIVE_SERVICE'); + +export interface NarrativeRequest { + reportType: string; + sectionKey: string; + sectionTitle: string; + context: Record; + locale?: string; +} + +export interface IAINarrativeService { + generateNarrative(request: NarrativeRequest): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/index.ts b/apps/api/src/modules/reports/domain/services/index.ts new file mode 100644 index 0000000..7ce26a3 --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/index.ts @@ -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'; diff --git a/apps/api/src/modules/reports/domain/services/infrastructure-data.service.ts b/apps/api/src/modules/reports/domain/services/infrastructure-data.service.ts new file mode 100644 index 0000000..2025f3f --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/infrastructure-data.service.ts @@ -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; + getByStatus(status: string): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/macro-data.service.ts b/apps/api/src/modules/reports/domain/services/macro-data.service.ts new file mode 100644 index 0000000..2a27d1b --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/macro-data.service.ts @@ -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; + getByIndicator(indicator: string, provinces?: string[]): Promise; + upsert(data: Omit & { source: string }): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/pdf-generator.service.ts b/apps/api/src/modules/reports/domain/services/pdf-generator.service.ts new file mode 100644 index 0000000..e0b4372 --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/pdf-generator.service.ts @@ -0,0 +1,5 @@ +export const PDF_GENERATOR_SERVICE = Symbol('PDF_GENERATOR_SERVICE'); + +export interface IPdfGeneratorService { + generatePdf(reportId: string, content: Record): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/pdf-storage.service.ts b/apps/api/src/modules/reports/domain/services/pdf-storage.service.ts new file mode 100644 index 0000000..4d9620c --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/pdf-storage.service.ts @@ -0,0 +1,5 @@ +export const PDF_STORAGE_SERVICE = Symbol('PDF_STORAGE_SERVICE'); + +export interface IPdfStorageService { + uploadPdf(buffer: Buffer, reportId: string): Promise; +} diff --git a/apps/api/src/modules/reports/domain/services/report-generator.service.ts b/apps/api/src/modules/reports/domain/services/report-generator.service.ts new file mode 100644 index 0000000..5b4c26e --- /dev/null +++ b/apps/api/src/modules/reports/domain/services/report-generator.service.ts @@ -0,0 +1,10 @@ +export const REPORT_GENERATOR_SERVICE = Symbol('REPORT_GENERATOR_SERVICE'); + +export interface ReportGenerationResult { + content: Record; + pdfUrl: string | null; +} + +export interface IReportGeneratorService { + generate(reportId: string): Promise; +} diff --git a/apps/api/src/modules/reports/index.ts b/apps/api/src/modules/reports/index.ts new file mode 100644 index 0000000..6db2170 --- /dev/null +++ b/apps/api/src/modules/reports/index.ts @@ -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'; diff --git a/apps/api/src/modules/reports/infrastructure/repositories/prisma-report.repository.ts b/apps/api/src/modules/reports/infrastructure/repositories/prisma-report.repository.ts new file mode 100644 index 0000000..98579de --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/repositories/prisma-report.repository.ts @@ -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 { + 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 = { 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 { + 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 { + 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 { + await this.prisma.report.delete({ where: { id } }); + } + + async countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise { + 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, + content: row.content as Record | null, + pdfUrl: row.pdfUrl, + status: row.status as string as ReportStatus, + errorMsg: row.errorMsg, + }, + row.createdAt, + row.updatedAt, + ); + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/claude-narrative.service.ts b/apps/api/src/modules/reports/infrastructure/services/claude-narrative.service.ts new file mode 100644 index 0000000..4112209 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/claude-narrative.service.ts @@ -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('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 { + 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 = { + 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.`; + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/infrastructure-data.service.ts b/apps/api/src/modules/reports/infrastructure/services/infrastructure-data.service.ts new file mode 100644 index 0000000..b71db2b --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/infrastructure-data.service.ts @@ -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 { + const where: Record = { 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 { + 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, + })); + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/macro-data.service.ts b/apps/api/src/modules/reports/infrastructure/services/macro-data.service.ts new file mode 100644 index 0000000..e13718c --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/macro-data.service.ts @@ -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 { + const where: Record = { 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 { + const where: Record = { 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 { + 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, + }, + }); + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts b/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts new file mode 100644 index 0000000..99814f6 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/minio-pdf-storage.service.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts b/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts new file mode 100644 index 0000000..b36ef3d --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/pdf-generator.service.ts @@ -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 = { + 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; + charts?: Record; + projects?: Array>; + summary?: Record; +} + +@Injectable() +export class PuppeteerPdfGenerationService implements IPdfGenerationService { + private readonly logger = new Logger(PuppeteerPdfGenerationService.name); + + async generatePdf( + title: string, + reportType: string, + content: Record, + ): Promise { + const generatedAt = (content['generatedAt'] as string) || new Date().toISOString(); + const sections = (content['sections'] as Record) || {}; + 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: '
', + footerTemplate: ` +
+ GoodGo AI Report — ${this.escapeHtml(title)} + Trang / +
+ `, + }); + + 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 { + 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 ` + + + + + + + + +
+ +

${this.escapeHtml(title)}

+
${this.escapeHtml(typeLabel)}
+
${this.escapeHtml(formattedDate)}
+
+ + Powered by AI +
+
+ + +
+

Mục lục

+ ${tocHtml} +
+ + + ${sectionsHtml} + + +
+

Phương pháp & Nguồn dữ liệu

+ +

Phương pháp phân tích

+
    +
  • Phân tích dữ liệu thị trường BĐS từ các sàn giao dịch lớn
  • +
  • Mô hình định giá tự động (AVM) dựa trên machine learning
  • +
  • Phân tích chuỗi thời gian cho dự báo xu hướng giá
  • +
  • Xử lý ngôn ngữ tự nhiên (NLP) cho phân tích tin tức và sentiment
  • +
  • Dữ liệu vĩ mô từ Tổng cục Thống kê và các nguồn chính thức
  • +
+ +

Nguồn dữ liệu

+
    +
  • GoodGo Platform — dữ liệu listings và giao dịch nội bộ
  • +
  • Tổng cục Thống kê Việt Nam (GSO)
  • +
  • Ngân hàng Nhà nước Việt Nam (SBV)
  • +
  • Bộ Kế hoạch và Đầu tư — dữ liệu FDI
  • +
  • OpenStreetMap & Google Maps — dữ liệu POI
  • +
+ +

Liên hệ

+
+

GoodGo AI Research

+

Email: research@goodgo.vn

+

Website: goodgo.vn

+
+ +
+ Miễn trừ trách nhiệm: 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. +
+
+ + +`; + } + + private buildToc(entries: Array<[string, ReportSection]>): string { + const items = entries + .map(([, section], index) => { + const title = section.title || 'Untitled'; + return `
  • ${index + 1}.${this.escapeHtml(title)}
  • `; + }) + .join('\n'); + + return `
      ${items}
    `; + } + + private buildSection(key: string, section: ReportSection, index: number): string { + const title = section.title || key; + const isExecutiveSummary = key === 'executive_summary'; + + let html = `
    `; + html += `
    `; + html += `${index}`; + html += `${this.escapeHtml(title)}`; + html += `
    `; + + // Text content + if (section.content) { + html += `

    ${this.escapeHtml(section.content)}

    `; + } + + // 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); + } + + // 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); + } + + html += `
    `; + 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 = ` + + + ${yLabels.map((yl) => ``).join('\n')} + + + + + + + + + + + + + + + + + ${points.map((p) => ``).join('\n')} + + + ${yLabels.map((yl) => `${formatNum(yl.val)}`).join('\n')} + + + ${xLabels.map((xl) => `${this.escapeHtml(xl.label)}`).join('\n')} + + `; + + return ` +
    +
    ${this.escapeHtml(chartLabel)}${unit ? ` (${this.escapeHtml(unit)})` : ''}
    + ${svg} +
    + `; + } + + private buildDataTables(data: Record): 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 += ` + + + + + + + + + + + + + ${items.map((item) => ` + + + + + + `).join('')} + +
    ${this.escapeHtml(label)}
    KỳGiá trịĐơn vị
    ${this.escapeHtml(String(item.period))}${typeof item.value === 'number' ? item.value.toLocaleString('vi-VN') : String(item.value)}${this.escapeHtml(String(item.unit || ''))}
    + `; + } + + return html; + } + + private buildProjectsTable(projects: Array>): string { + if (projects.length === 0) return ''; + + return ` + + + + + + + + + + + ${projects.map((p) => ` + + + + + + + `).join('')} + +
    Dự ánDanh mụcTrạng tháiVốn đầu tư (VND)
    ${this.escapeHtml(String(p['name'] || ''))}${this.escapeHtml(String(p['category'] || ''))}${this.escapeHtml(String(p['status'] || ''))}${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') : '—'}
    + `; + } + + private buildSummary(summary: Record): string { + let html = '
    '; + + 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 += `

    ${this.escapeHtml(label)}: ${val.toLocaleString('vi-VN')}

    `; + } else if (typeof val === 'object' && val !== null) { + html += `

    ${this.escapeHtml(label)}:

    `; + html += '
      '; + for (const [k, v] of Object.entries(val as Record)) { + html += `
    • ${this.escapeHtml(k)}: ${String(v)}
    • `; + } + html += '
    '; + } + } + + html += '
    '; + return html; + } + + private escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts b/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts new file mode 100644 index 0000000..312e915 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/pdf-storage.service.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts b/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts new file mode 100644 index 0000000..c1e72e7 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/puppeteer-pdf.service.ts @@ -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; + projects?: Array>; + summary?: Record; +} + +@Injectable() +export class PuppeteerPdfService implements IPdfGeneratorService { + private readonly logger = new Logger(PuppeteerPdfService.name); + + async generatePdf(reportId: string, content: Record): Promise { + 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 { + 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 | undefined; + + const sectionHtml = sections + ? Object.entries(sections).map(([key, section]) => this.renderSection(key, section)).join('\n') + : '

    Không có dữ liệu.

    '; + + return ` + + + + + + +
    +

    ${this.escapeHtml(reportType)} — ${this.escapeHtml(province)}

    +
    GoodGo Platform AI | Ngày tạo: ${new Date(generatedAt).toLocaleDateString('vi-VN')}
    +
    + ${sectionHtml} + + +`; + } + + private renderSection(key: string, section: ReportSection): string { + let body = ''; + + if (section.content) { + body += `

    ${this.escapeHtml(section.content)}

    `; + } + + if (section.projects && Array.isArray(section.projects) && section.projects.length > 0) { + body += ''; + for (const p of section.projects) { + body += ` + + + + + `; + } + body += '
    TênLoạiTrạng tháiVốn đầu tư
    ${this.escapeHtml(String(p['name'] ?? ''))}${this.escapeHtml(String(p['category'] ?? ''))}${this.escapeHtml(String(p['status'] ?? ''))}${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') + ' VND' : '—'}
    '; + } + + 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 += '
    '; + 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 += `
    +

    ${this.escapeHtml(indicator)}

    + ${typed.value.toLocaleString('vi-VN')} ${this.escapeHtml(typed.unit)} +
    ${this.escapeHtml(typed.period)} +
    `; + } + body += '
    '; + } + } + + return `

    ${this.escapeHtml(section.title)}

    ${body}
    `; + } + + private escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } +} diff --git a/apps/api/src/modules/reports/infrastructure/services/report-generation.processor.ts b/apps/api/src/modules/reports/infrastructure/services/report-generation.processor.ts new file mode 100644 index 0000000..50ed175 --- /dev/null +++ b/apps/api/src/modules/reports/infrastructure/services/report-generation.processor.ts @@ -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): Promise { + 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, + ): Promise> { + 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, + ): Promise> { + 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> = {}; + 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>, 'category'), + byStatus: this.groupBy(infraProjects as unknown as Array>, '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, + ): Promise> { + 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> = {}; + 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, + ): Promise> { + 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, + ): Promise> { + 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 { + const result: Record = {}; + for (const item of items) { + const val = String(item[key]); + result[val] = (result[val] ?? 0) + 1; + } + return result; + } +} diff --git a/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts b/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts new file mode 100644 index 0000000..016d887 --- /dev/null +++ b/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts @@ -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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/modules/reports/presentation/dto/generate-report.dto.ts b/apps/api/src/modules/reports/presentation/dto/generate-report.dto.ts new file mode 100644 index 0000000..5f234fb --- /dev/null +++ b/apps/api/src/modules/reports/presentation/dto/generate-report.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/reports/presentation/dto/index.ts b/apps/api/src/modules/reports/presentation/dto/index.ts new file mode 100644 index 0000000..04dfb4f --- /dev/null +++ b/apps/api/src/modules/reports/presentation/dto/index.ts @@ -0,0 +1,2 @@ +export { GenerateReportDto } from './generate-report.dto'; +export { ListReportsDto } from './list-reports.dto'; diff --git a/apps/api/src/modules/reports/presentation/dto/list-reports.dto.ts b/apps/api/src/modules/reports/presentation/dto/list-reports.dto.ts new file mode 100644 index 0000000..60ccd79 --- /dev/null +++ b/apps/api/src/modules/reports/presentation/dto/list-reports.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/reports/reports.module.ts b/apps/api/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..691a946 --- /dev/null +++ b/apps/api/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.command.ts b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.command.ts new file mode 100644 index 0000000..831bf70 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.command.ts @@ -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; + 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[], + ) {} +} diff --git a/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.handler.ts b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.handler.ts new file mode 100644 index 0000000..56ac8c1 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/create-transfer-listing.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/transfer/application/commands/create-transfer-listing/index.ts b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/index.ts new file mode 100644 index 0000000..31cfb9e --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/create-transfer-listing/index.ts @@ -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'; diff --git a/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.command.ts b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.command.ts new file mode 100644 index 0000000..c18b785 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.command.ts @@ -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[], + ) {} +} diff --git a/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.handler.ts b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.handler.ts new file mode 100644 index 0000000..7dd55c9 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/estimate-transfer-prices.handler.ts @@ -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 { + 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, + }; + } +} diff --git a/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/index.ts b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/index.ts new file mode 100644 index 0000000..02ad6f2 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-transfer-prices/index.ts @@ -0,0 +1,2 @@ +export { EstimateTransferPricesCommand, type EstimateItemInput } from './estimate-transfer-prices.command'; +export { EstimateTransferPricesHandler } from './estimate-transfer-prices.handler'; diff --git a/apps/api/src/modules/transfer/application/commands/index.ts b/apps/api/src/modules/transfer/application/commands/index.ts new file mode 100644 index 0000000..187bd8c --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/index.ts @@ -0,0 +1,2 @@ +export * from './create-transfer-listing'; +export * from './update-transfer-listing'; diff --git a/apps/api/src/modules/transfer/application/commands/update-transfer-listing/index.ts b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/index.ts new file mode 100644 index 0000000..e53dfa0 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/index.ts @@ -0,0 +1,2 @@ +export { UpdateTransferListingCommand } from './update-transfer-listing.command'; +export { UpdateTransferListingHandler } from './update-transfer-listing.handler'; diff --git a/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.command.ts b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.command.ts new file mode 100644 index 0000000..3041f20 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.command.ts @@ -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[], + ) {} +} diff --git a/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.handler.ts b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.handler.ts new file mode 100644 index 0000000..d603a4b --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/update-transfer-listing/update-transfer-listing.handler.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.handler.ts b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.handler.ts new file mode 100644 index 0000000..801f22b --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.handler.ts @@ -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 { + constructor( + @Inject(TRANSFER_LISTING_REPOSITORY) + private readonly repo: ITransferListingRepository, + ) {} + + async execute(query: GetTransferListingQuery) { + return this.repo.findDetailById(query.id); + } +} diff --git a/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.query.ts b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.query.ts new file mode 100644 index 0000000..b48418c --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/get-transfer-listing.query.ts @@ -0,0 +1,5 @@ +export class GetTransferListingQuery { + constructor( + public readonly id: string, + ) {} +} diff --git a/apps/api/src/modules/transfer/application/queries/get-transfer-listing/index.ts b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/index.ts new file mode 100644 index 0000000..9634a85 --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/get-transfer-listing/index.ts @@ -0,0 +1,2 @@ +export { GetTransferListingQuery } from './get-transfer-listing.query'; +export { GetTransferListingHandler } from './get-transfer-listing.handler'; diff --git a/apps/api/src/modules/transfer/application/queries/index.ts b/apps/api/src/modules/transfer/application/queries/index.ts new file mode 100644 index 0000000..907c052 --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/index.ts @@ -0,0 +1,3 @@ +export * from './list-transfer-listings'; +export * from './get-transfer-listing'; +export * from './transfer-stats'; diff --git a/apps/api/src/modules/transfer/application/queries/list-transfer-listings/index.ts b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/index.ts new file mode 100644 index 0000000..cda7ba4 --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/index.ts @@ -0,0 +1,2 @@ +export { ListTransferListingsQuery } from './list-transfer-listings.query'; +export { ListTransferListingsHandler } from './list-transfer-listings.handler'; diff --git a/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.handler.ts b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.handler.ts new file mode 100644 index 0000000..b839e0f --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.handler.ts @@ -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 { + 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, + }); + } +} diff --git a/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.query.ts b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.query.ts new file mode 100644 index 0000000..efcaf61 --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/list-transfer-listings/list-transfer-listings.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/transfer/application/queries/transfer-stats/index.ts b/apps/api/src/modules/transfer/application/queries/transfer-stats/index.ts new file mode 100644 index 0000000..fb045d9 --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/transfer-stats/index.ts @@ -0,0 +1,2 @@ +export { TransferStatsQuery } from './transfer-stats.query'; +export { TransferStatsHandler } from './transfer-stats.handler'; diff --git a/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.handler.ts b/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.handler.ts new file mode 100644 index 0000000..fd844aa --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.handler.ts @@ -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 { + constructor( + @Inject(TRANSFER_LISTING_REPOSITORY) + private readonly repo: ITransferListingRepository, + ) {} + + async execute(_query: TransferStatsQuery) { + return this.repo.getStats(); + } +} diff --git a/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.query.ts b/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.query.ts new file mode 100644 index 0000000..cf29bbd --- /dev/null +++ b/apps/api/src/modules/transfer/application/queries/transfer-stats/transfer-stats.query.ts @@ -0,0 +1 @@ +export class TransferStatsQuery {} diff --git a/apps/api/src/modules/transfer/domain/entities/index.ts b/apps/api/src/modules/transfer/domain/entities/index.ts new file mode 100644 index 0000000..f2a0332 --- /dev/null +++ b/apps/api/src/modules/transfer/domain/entities/index.ts @@ -0,0 +1 @@ +export { TransferListingEntity, type TransferListingProps } from './transfer-listing.entity'; diff --git a/apps/api/src/modules/transfer/domain/entities/transfer-listing.entity.ts b/apps/api/src/modules/transfer/domain/entities/transfer-listing.entity.ts new file mode 100644 index 0000000..247effe --- /dev/null +++ b/apps/api/src/modules/transfer/domain/entities/transfer-listing.entity.ts @@ -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[] | 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 { + 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[] | 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): 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(); + } +} diff --git a/apps/api/src/modules/transfer/domain/events/index.ts b/apps/api/src/modules/transfer/domain/events/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/api/src/modules/transfer/domain/events/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/api/src/modules/transfer/domain/repositories/index.ts b/apps/api/src/modules/transfer/domain/repositories/index.ts new file mode 100644 index 0000000..abda324 --- /dev/null +++ b/apps/api/src/modules/transfer/domain/repositories/index.ts @@ -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'; diff --git a/apps/api/src/modules/transfer/domain/repositories/transfer-listing.repository.ts b/apps/api/src/modules/transfer/domain/repositories/transfer-listing.repository.ts new file mode 100644 index 0000000..5e41be8 --- /dev/null +++ b/apps/api/src/modules/transfer/domain/repositories/transfer-listing.repository.ts @@ -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 { + 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[] | 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 | null; + media: Record[] | 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[] | 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; + findDetailById(id: string): Promise; + save(entity: TransferListingEntity, items: CreateTransferItemInput[]): Promise; + update(entity: TransferListingEntity): Promise; + search(params: TransferListingSearchParams): Promise>; + getStats(): Promise; +} diff --git a/apps/api/src/modules/transfer/domain/services/furniture-pricing.service.ts b/apps/api/src/modules/transfer/domain/services/furniture-pricing.service.ts new file mode 100644 index 0000000..45d5a29 --- /dev/null +++ b/apps/api/src/modules/transfer/domain/services/furniture-pricing.service.ts @@ -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 = { + 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 = { + 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; +} diff --git a/apps/api/src/modules/transfer/domain/services/index.ts b/apps/api/src/modules/transfer/domain/services/index.ts new file mode 100644 index 0000000..fc1ea11 --- /dev/null +++ b/apps/api/src/modules/transfer/domain/services/index.ts @@ -0,0 +1,6 @@ +export { + estimateFurniturePrice, + estimateTransferListingPrices, + type FurniturePriceEstimate, + type FurniturePricingInput, +} from './furniture-pricing.service'; diff --git a/apps/api/src/modules/transfer/domain/value-objects/index.ts b/apps/api/src/modules/transfer/domain/value-objects/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/api/src/modules/transfer/domain/value-objects/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/api/src/modules/transfer/index.ts b/apps/api/src/modules/transfer/index.ts new file mode 100644 index 0000000..33e6290 --- /dev/null +++ b/apps/api/src/modules/transfer/index.ts @@ -0,0 +1,10 @@ +export { TransferModule } from './transfer.module'; +export { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository'; +export type { + ITransferListingRepository, + TransferListingDetailData, + TransferListingListItem, + TransferItemData, + TransferStatsData, +} from './domain/repositories/transfer-listing.repository'; +export { TransferListingEntity, type TransferListingProps } from './domain/entities/transfer-listing.entity'; diff --git a/apps/api/src/modules/transfer/infrastructure/repositories/index.ts b/apps/api/src/modules/transfer/infrastructure/repositories/index.ts new file mode 100644 index 0000000..563b65a --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/repositories/index.ts @@ -0,0 +1 @@ +export { PrismaTransferListingRepository } from './prisma-transfer-listing.repository'; diff --git a/apps/api/src/modules/transfer/infrastructure/repositories/prisma-transfer-listing.repository.ts b/apps/api/src/modules/transfer/infrastructure/repositories/prisma-transfer-listing.repository.ts new file mode 100644 index 0000000..1748168 --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/repositories/prisma-transfer-listing.repository.ts @@ -0,0 +1,447 @@ +import { Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import type { Prisma } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import type { CreateTransferItemInput } from '../../application/commands/create-transfer-listing/create-transfer-listing.command'; +import { TransferListingEntity } from '../../domain/entities/transfer-listing.entity'; +import type { + ITransferListingRepository, + TransferListingSearchParams, + PaginatedResult, + TransferListingListItem, + TransferListingDetailData, + TransferItemData, + TransferStatsData, +} from '../../domain/repositories/transfer-listing.repository'; + +interface RawTransferListing { + id: string; + sellerId: string; + category: string; + status: string; + title: string; + description: string | null; + address: string; + ward: string | null; + district: string; + city: string; + lat: number; + lng: number; + askingPriceVND: bigint; + aiEstimatePriceVND: bigint | null; + aiConfidence: number | null; + pricingSource: string; + isNegotiable: boolean; + areaM2: number | null; + monthlyRentVND: bigint | null; + depositMonths: number | null; + remainingLeaseMo: number | null; + businessType: string | null; + footTraffic: string | null; + media: Prisma.JsonValue; + 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; + createdAt: Date; + updatedAt: Date; +} + +interface RawTransferListingWithCount extends RawTransferListing { + itemCount: number; +} + +interface RawTransferItem { + id: string; + transferListingId: string; + name: string; + brand: string | null; + modelName: string | null; + category: string; + condition: string; + purchaseYear: number | null; + originalPriceVND: bigint | null; + askingPriceVND: bigint; + aiEstimatePriceVND: bigint | null; + aiConfidence: number | null; + quantity: number; + dimensions: Prisma.JsonValue; + media: Prisma.JsonValue; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} + + +@Injectable() +export class PrismaTransferListingRepository implements ITransferListingRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const rows = await this.prisma.$queryRaw` + SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng + FROM "TransferListing" WHERE id = ${id} LIMIT 1 + `; + return rows[0] ? this.toDomain(rows[0]) : null; + } + + async findDetailById(id: string): Promise { + const rows = await this.prisma.$queryRaw` + SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng + FROM "TransferListing" WHERE id = ${id} LIMIT 1 + `; + + if (!rows[0]) return null; + + const items = await this.prisma.$queryRaw` + SELECT * FROM "TransferItem" WHERE "transferListingId" = ${id} + `; + + return this.toDetail(rows[0], items); + } + + async save(entity: TransferListingEntity, items: CreateTransferItemInput[]): Promise { + await this.prisma.$transaction(async (tx) => { + await tx.$executeRaw` + INSERT INTO "TransferListing" ( + id, "sellerId", category, status, title, description, + address, ward, district, city, location, + "askingPriceVND", "aiEstimatePriceVND", "aiConfidence", "pricingSource", + "isNegotiable", "areaM2", "monthlyRentVND", "depositMonths", + "remainingLeaseMo", "businessType", "footTraffic", media, + "moderationScore", "moderationNotes", + "viewCount", "saveCount", "inquiryCount", + "contactPhone", "contactName", + "featuredUntil", "expiresAt", "publishedAt", + "createdAt", "updatedAt" + ) VALUES ( + ${entity.id}, ${entity.sellerId}, + ${entity.category}::"TransferCategory", + ${entity.status}::"TransferListingStatus", + ${entity.title}, ${entity.description}, + ${entity.address}, ${entity.ward}, ${entity.district}, ${entity.city}, + ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326), + ${entity.askingPriceVND}, ${entity.aiEstimatePriceVND}, ${entity.aiConfidence}, + ${entity.pricingSource}::"TransferPricingSource", + ${entity.isNegotiable}, ${entity.areaM2}, ${entity.monthlyRentVND}, + ${entity.depositMonths}, ${entity.remainingLeaseMo}, + ${entity.businessType}, ${entity.footTraffic}, + ${entity.media ? JSON.stringify(entity.media) : null}::jsonb, + ${entity.moderationScore}, ${entity.moderationNotes}, + ${entity.viewCount}, ${entity.saveCount}, ${entity.inquiryCount}, + ${entity.contactPhone}, ${entity.contactName}, + ${entity.featuredUntil}, ${entity.expiresAt}, ${entity.publishedAt}, + ${entity.createdAt}, ${entity.updatedAt} + ) + `; + + if (items.length > 0) { + const now = new Date(); + for (const item of items) { + const itemId = createId(); + await tx.$executeRaw` + INSERT INTO "TransferItem" ( + id, "transferListingId", name, brand, "modelName", + category, condition, "purchaseYear", + "originalPriceVND", "askingPriceVND", + "aiEstimatePriceVND", "aiConfidence", + quantity, dimensions, media, notes, + "createdAt", "updatedAt" + ) VALUES ( + ${itemId}, ${entity.id}, + ${item.name}, ${item.brand ?? null}, ${item.modelName ?? null}, + ${item.category}::"TransferCategory", + ${item.condition}::"TransferCondition", + ${item.purchaseYear ?? null}, + ${item.originalPriceVND ?? null}, ${item.askingPriceVND}, + ${null}::bigint, ${null}::float8, + ${item.quantity ?? 1}, + ${item.dimensions ? JSON.stringify(item.dimensions) : null}::jsonb, + ${null}::jsonb, + ${item.notes ?? null}, + ${now}, ${now} + ) + `; + } + } + }); + } + + async update(entity: TransferListingEntity): Promise { + await this.prisma.$executeRaw` + UPDATE "TransferListing" SET + "sellerId" = ${entity.sellerId}, + category = ${entity.category}::"TransferCategory", + status = ${entity.status}::"TransferListingStatus", + title = ${entity.title}, + description = ${entity.description}, + address = ${entity.address}, + ward = ${entity.ward}, + district = ${entity.district}, + city = ${entity.city}, + location = ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326), + "askingPriceVND" = ${entity.askingPriceVND}, + "aiEstimatePriceVND" = ${entity.aiEstimatePriceVND}, + "aiConfidence" = ${entity.aiConfidence}, + "pricingSource" = ${entity.pricingSource}::"TransferPricingSource", + "isNegotiable" = ${entity.isNegotiable}, + "areaM2" = ${entity.areaM2}, + "monthlyRentVND" = ${entity.monthlyRentVND}, + "depositMonths" = ${entity.depositMonths}, + "remainingLeaseMo" = ${entity.remainingLeaseMo}, + "businessType" = ${entity.businessType}, + "footTraffic" = ${entity.footTraffic}, + media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb, + "moderationScore" = ${entity.moderationScore}, + "moderationNotes" = ${entity.moderationNotes}, + "viewCount" = ${entity.viewCount}, + "saveCount" = ${entity.saveCount}, + "inquiryCount" = ${entity.inquiryCount}, + "contactPhone" = ${entity.contactPhone}, + "contactName" = ${entity.contactName}, + "featuredUntil" = ${entity.featuredUntil}, + "expiresAt" = ${entity.expiresAt}, + "publishedAt" = ${entity.publishedAt}, + "updatedAt" = ${entity.updatedAt} + WHERE id = ${entity.id} + `; + } + + async search(params: TransferListingSearchParams): Promise> { + 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.category) { + conditions.push(`category = $${paramIndex++}::"TransferCategory"`); + values.push(params.category); + } + if (params.status) { + conditions.push(`status = $${paramIndex++}::"TransferListingStatus"`); + values.push(params.status); + } + if (params.district) { + conditions.push(`district = $${paramIndex++}`); + values.push(params.district); + } + if (params.city) { + conditions.push(`city = $${paramIndex++}`); + values.push(params.city); + } + if (params.minPrice != null) { + conditions.push(`"askingPriceVND" >= $${paramIndex++}`); + values.push(params.minPrice); + } + if (params.maxPrice != null) { + conditions.push(`"askingPriceVND" <= $${paramIndex++}`); + values.push(params.maxPrice); + } + if (params.query) { + conditions.push( + `(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR address ILIKE $${paramIndex} OR district 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 "TransferListing" WHERE ${where}`, + ...values, + ); + const total = Number(countResult[0].count); + + const rows = await this.prisma.$queryRawUnsafe( + `SELECT t.*, ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng, + (SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount" + FROM "TransferListing" t WHERE ${where} + ORDER BY "publishedAt" DESC NULLS LAST, t."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 getStats(): Promise { + const [summary] = await this.prisma.$queryRaw<[{ totalListings: bigint; totalValue: bigint }]>` + SELECT COUNT(*)::bigint as "totalListings", + COALESCE(SUM("askingPriceVND"), 0)::bigint as "totalValue" + FROM "TransferListing" WHERE status = 'ACTIVE' + `; + + const byCategory = await this.prisma.$queryRaw<{ category: string; count: bigint; avgPrice: number }[]>` + SELECT category::text, COUNT(*)::bigint as count, + AVG("askingPriceVND"::numeric)::float as "avgPrice" + FROM "TransferListing" WHERE status = 'ACTIVE' + GROUP BY category ORDER BY count DESC + `; + + const byDistrict = await this.prisma.$queryRaw<{ district: string; count: bigint; avgPrice: number }[]>` + SELECT district, COUNT(*)::bigint as count, + AVG("askingPriceVND"::numeric)::float as "avgPrice" + FROM "TransferListing" WHERE status = 'ACTIVE' + GROUP BY district ORDER BY count DESC + `; + + const byStatus = await this.prisma.$queryRaw<{ status: string; count: bigint }[]>` + SELECT status::text, COUNT(*)::bigint as count + FROM "TransferListing" GROUP BY status ORDER BY count DESC + `; + + return { + totalListings: Number(summary.totalListings), + totalValue: summary.totalValue, + byCategory: byCategory.map((r) => ({ category: r.category, count: Number(r.count), avgPrice: r.avgPrice })), + byDistrict: byDistrict.map((r) => ({ district: r.district, count: Number(r.count), avgPrice: r.avgPrice })), + byStatus: byStatus.map((r) => ({ status: r.status, count: Number(r.count) })), + }; + } + + private toDomain(row: RawTransferListing): TransferListingEntity { + return new TransferListingEntity( + row.id, + { + sellerId: row.sellerId, + category: row.category as TransferListingEntity['category'], + status: row.status as TransferListingEntity['status'], + title: row.title, + description: row.description, + address: row.address, + ward: row.ward, + district: row.district, + city: row.city, + latitude: Number(row.lat), + longitude: Number(row.lng), + askingPriceVND: row.askingPriceVND, + aiEstimatePriceVND: row.aiEstimatePriceVND, + aiConfidence: row.aiConfidence, + pricingSource: row.pricingSource as TransferListingEntity['pricingSource'], + isNegotiable: row.isNegotiable, + areaM2: row.areaM2, + monthlyRentVND: row.monthlyRentVND, + depositMonths: row.depositMonths, + remainingLeaseMo: row.remainingLeaseMo, + businessType: row.businessType, + footTraffic: row.footTraffic, + media: row.media as Record[] | null, + moderationScore: row.moderationScore, + moderationNotes: row.moderationNotes, + viewCount: row.viewCount, + saveCount: row.saveCount, + inquiryCount: row.inquiryCount, + contactPhone: row.contactPhone, + contactName: row.contactName, + featuredUntil: row.featuredUntil, + expiresAt: row.expiresAt, + publishedAt: row.publishedAt, + }, + row.createdAt, + row.updatedAt, + ); + } + + private toListItem(row: RawTransferListingWithCount): TransferListingListItem { + return { + id: row.id, + sellerId: row.sellerId, + category: row.category as TransferListingListItem['category'], + status: row.status as TransferListingListItem['status'], + title: row.title, + address: row.address, + district: row.district, + city: row.city, + latitude: Number(row.lat), + longitude: Number(row.lng), + askingPriceVND: row.askingPriceVND, + aiEstimatePriceVND: row.aiEstimatePriceVND, + pricingSource: row.pricingSource as TransferListingListItem['pricingSource'], + isNegotiable: row.isNegotiable, + areaM2: row.areaM2, + media: row.media as Record[] | null, + viewCount: row.viewCount, + inquiryCount: row.inquiryCount, + publishedAt: row.publishedAt, + itemCount: row.itemCount, + }; + } + + private toDetail(row: RawTransferListing, items: RawTransferItem[]): TransferListingDetailData { + return { + id: row.id, + sellerId: row.sellerId, + category: row.category as TransferListingDetailData['category'], + status: row.status as TransferListingDetailData['status'], + title: row.title, + description: row.description, + address: row.address, + ward: row.ward, + district: row.district, + city: row.city, + latitude: Number(row.lat), + longitude: Number(row.lng), + askingPriceVND: row.askingPriceVND, + aiEstimatePriceVND: row.aiEstimatePriceVND, + aiConfidence: row.aiConfidence, + pricingSource: row.pricingSource as TransferListingDetailData['pricingSource'], + isNegotiable: row.isNegotiable, + areaM2: row.areaM2, + monthlyRentVND: row.monthlyRentVND, + depositMonths: row.depositMonths, + remainingLeaseMo: row.remainingLeaseMo, + businessType: row.businessType, + footTraffic: row.footTraffic, + media: row.media as Record[] | null, + moderationScore: row.moderationScore, + moderationNotes: row.moderationNotes, + viewCount: row.viewCount, + saveCount: row.saveCount, + inquiryCount: row.inquiryCount, + contactPhone: row.contactPhone, + contactName: row.contactName, + featuredUntil: row.featuredUntil, + expiresAt: row.expiresAt, + publishedAt: row.publishedAt, + items: items.map((i) => this.toItemData(i)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private toItemData(row: RawTransferItem): TransferItemData { + return { + id: row.id, + name: row.name, + brand: row.brand, + modelName: row.modelName, + category: row.category as TransferItemData['category'], + condition: row.condition as TransferItemData['condition'], + purchaseYear: row.purchaseYear, + originalPriceVND: row.originalPriceVND, + askingPriceVND: row.askingPriceVND, + aiEstimatePriceVND: row.aiEstimatePriceVND, + aiConfidence: row.aiConfidence, + quantity: row.quantity, + dimensions: row.dimensions as Record | null, + media: row.media as Record[] | null, + notes: row.notes, + }; + } +} diff --git a/apps/api/src/modules/transfer/infrastructure/services/index.ts b/apps/api/src/modules/transfer/infrastructure/services/index.ts new file mode 100644 index 0000000..57e633e --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/services/index.ts @@ -0,0 +1 @@ +export { TypesenseTransferService, TRANSFER_LISTINGS_COLLECTION } from './typesense-transfer.service'; diff --git a/apps/api/src/modules/transfer/infrastructure/services/typesense-transfer.service.ts b/apps/api/src/modules/transfer/infrastructure/services/typesense-transfer.service.ts new file mode 100644 index 0000000..2aa5b6a --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/services/typesense-transfer.service.ts @@ -0,0 +1,183 @@ +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 TRANSFER_LISTINGS_COLLECTION = 'transfer_listings'; + +const COLLECTION_SCHEMA: CollectionCreateSchema = { + name: TRANSFER_LISTINGS_COLLECTION, + enable_nested_fields: false, + token_separators: ['-', '_'], + fields: [ + { name: 'listingId', type: 'string', facet: false }, + { name: 'title', type: 'string', facet: false }, + { name: 'description', type: 'string', facet: false, optional: true }, + { name: 'category', type: 'string', facet: true }, + { name: 'status', type: 'string', facet: true }, + { name: 'district', type: 'string', facet: true }, + { name: 'city', type: 'string', facet: true }, + { name: 'address', type: 'string', facet: false }, + { name: 'askingPriceVND', type: 'int64', facet: false }, + { name: 'isNegotiable', type: 'bool', facet: true }, + { name: 'areaM2', type: 'float', facet: false, optional: true }, + { name: 'hasPremises', type: 'bool', facet: true }, + { name: 'itemCount', type: 'int32', facet: false }, + { name: 'location', type: 'geopoint', facet: false }, + { name: 'sellerId', type: 'string', facet: false }, + { name: 'viewCount', type: 'int32', facet: false }, + { name: 'inquiryCount', type: 'int32', facet: false }, + { name: 'publishedAt', type: 'int64', facet: false, optional: true }, + { name: 'createdAt', type: 'int64', facet: false }, + ], +}; + +interface RawTransferListingForSync { + id: string; + sellerId: string; + title: string; + description: string | null; + category: string; + status: string; + district: string; + city: string; + address: string; + askingPriceVND: bigint; + isNegotiable: boolean; + areaM2: number | null; + viewCount: number; + inquiryCount: number; + publishedAt: Date | null; + createdAt: Date; + lat: number; + lng: number; + itemCount: number; +} + +@Injectable() +export class TypesenseTransferService implements OnModuleInit { + private client: TypesenseClient | null = null; + + constructor( + private readonly typesenseClient: TypesenseClientService, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async onModuleInit(): Promise { + try { + this.client = this.typesenseClient.getClient(); + await this.ensureCollection(); + await this.syncListings(); + } catch (err) { + this.logger.warn(`Typesense transfer init failed (non-fatal): ${err}`, 'TypesenseTransfer'); + } + } + + private async ensureCollection(): Promise { + if (!this.client) return; + + try { + await this.client.collections(TRANSFER_LISTINGS_COLLECTION).retrieve(); + this.logger.log(`Collection "${TRANSFER_LISTINGS_COLLECTION}" exists`, 'TypesenseTransfer'); + } catch { + await this.client.collections().create(COLLECTION_SCHEMA); + this.logger.log(`Collection "${TRANSFER_LISTINGS_COLLECTION}" created`, 'TypesenseTransfer'); + } + } + + async syncListings(): Promise { + if (!this.client) return; + + const listings = await this.prisma.$queryRaw` + SELECT t.id, t."sellerId", t.title, t.description, + t.category::text, t.status::text, + t.district, t.city, t.address, + t."askingPriceVND", t."isNegotiable", t."areaM2", + t."viewCount", t."inquiryCount", + t."publishedAt", t."createdAt", + ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng, + (SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount" + FROM "TransferListing" t + WHERE t.status = 'ACTIVE' + `; + + if (listings.length === 0) return; + + const docs = listings.map((l) => ({ + id: l.id, + listingId: l.id, + title: l.title, + description: l.description ?? undefined, + category: l.category.toLowerCase(), + status: l.status.toLowerCase(), + district: l.district, + city: l.city, + address: l.address, + askingPriceVND: Number(l.askingPriceVND), + isNegotiable: l.isNegotiable, + areaM2: l.areaM2 ?? undefined, + hasPremises: (l.areaM2 ?? 0) > 0, + itemCount: l.itemCount, + location: [Number(l.lat), Number(l.lng)], + sellerId: l.sellerId, + viewCount: l.viewCount, + inquiryCount: l.inquiryCount, + publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : undefined, + createdAt: Math.floor(l.createdAt.getTime() / 1000), + })); + + try { + const jsonl = docs.map((d) => JSON.stringify(d)).join('\n'); + await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents().import(jsonl, { action: 'upsert' }); + this.logger.log(`Synced ${docs.length} transfer listings to Typesense`, 'TypesenseTransfer'); + } catch (err) { + this.logger.warn(`Transfer listing sync error: ${err}`, 'TypesenseTransfer'); + } + } + + async indexListing(listingId: string): Promise { + if (!this.client) return; + + const [listing] = await this.prisma.$queryRaw` + SELECT t.id, t."sellerId", t.title, t.description, + t.category::text, t.status::text, + t.district, t.city, t.address, + t."askingPriceVND", t."isNegotiable", t."areaM2", + t."viewCount", t."inquiryCount", + t."publishedAt", t."createdAt", + ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng, + (SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount" + FROM "TransferListing" t + WHERE t.id = ${listingId} + `; + + if (!listing) return; + + const doc = { + id: listing.id, + listingId: listing.id, + title: listing.title, + description: listing.description ?? undefined, + category: listing.category.toLowerCase(), + status: listing.status.toLowerCase(), + district: listing.district, + city: listing.city, + address: listing.address, + askingPriceVND: Number(listing.askingPriceVND), + isNegotiable: listing.isNegotiable, + areaM2: listing.areaM2 ?? undefined, + hasPremises: (listing.areaM2 ?? 0) > 0, + itemCount: listing.itemCount, + location: [Number(listing.lat), Number(listing.lng)], + sellerId: listing.sellerId, + viewCount: listing.viewCount, + inquiryCount: listing.inquiryCount, + publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : undefined, + createdAt: Math.floor(listing.createdAt.getTime() / 1000), + }; + + await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents().upsert(doc); + } +} diff --git a/apps/api/src/modules/transfer/presentation/controllers/index.ts b/apps/api/src/modules/transfer/presentation/controllers/index.ts new file mode 100644 index 0000000..c849060 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/controllers/index.ts @@ -0,0 +1 @@ +export { TransferController } from './transfer.controller'; diff --git a/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts b/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts new file mode 100644 index 0000000..f4a4e00 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts @@ -0,0 +1,155 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard, CurrentUser } from '@modules/auth'; +import { NotFoundException } from '@modules/shared'; +import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command'; +import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command'; +import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command'; +import { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query'; +import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query'; +import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query'; +import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto'; +import { type EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto'; +import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto'; +import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.dto'; + +@ApiTags('transfer') +@Controller('transfer') +export class TransferController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + // ── Public endpoints ────────────────────────────────────────────── + + @ApiOperation({ summary: 'Danh sách sang nhượng', description: 'Tìm kiếm và lọc tin sang nhượng' }) + @ApiResponse({ status: 200, description: 'Danh sách tin sang nhượng phân trang' }) + @Get('listings') + async listListings(@Query() dto: SearchTransferListingsDto) { + return this.queryBus.execute( + new ListTransferListingsQuery( + dto.q, + dto.category, + dto.status, + dto.district, + dto.city, + dto.minPrice, + dto.maxPrice, + undefined, // sellerId — only used for "my listings" queries + dto.page ?? 1, + dto.limit ?? 20, + ), + ); + } + + @ApiOperation({ summary: 'Chi tiết tin sang nhượng', description: 'Xem chi tiết tin sang nhượng theo ID' }) + @ApiResponse({ status: 200, description: 'Thông tin chi tiết tin sang nhượng' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy tin sang nhượng' }) + @Get('listings/:id') + async getListing(@Param('id') id: string) { + const result = await this.queryBus.execute(new GetTransferListingQuery(id)); + if (!result) { + throw new NotFoundException('Transfer listing', id); + } + return result; + } + + @ApiOperation({ summary: 'Thống kê sang nhượng', description: 'Tổng quan thống kê tin sang nhượng' }) + @ApiResponse({ status: 200, description: 'Dữ liệu thống kê' }) + @Get('stats') + async getStats() { + return this.queryBus.execute(new TransferStatsQuery()); + } + + @ApiOperation({ summary: 'Ước tính giá AI', description: 'Ước tính giá trị nội thất/thiết bị dựa trên khấu hao, thương hiệu và tình trạng' }) + @ApiResponse({ status: 200, description: 'Kết quả ước tính giá' }) + @Post('estimate') + async estimatePrices(@Body() dto: EstimateTransferPricesDto) { + return this.commandBus.execute( + new EstimateTransferPricesCommand(dto.items), + ); + } + + // ── Authenticated endpoints ─────────────────────────────────────── + + @ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' }) + @ApiResponse({ status: 201, description: 'Tin sang nhượng đã tạo' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard) + @Post('listings') + async createListing( + @CurrentUser() user: { sub: string }, + @Body() dto: CreateTransferListingDto, + ) { + return this.commandBus.execute( + new CreateTransferListingCommand( + user.sub, + dto.category, + dto.title, + dto.description ?? null, + dto.address, + dto.ward ?? null, + dto.district, + dto.city, + dto.latitude, + dto.longitude, + BigInt(Math.round(dto.askingPriceVND)), + dto.pricingSource ?? 'MANUAL', + dto.isNegotiable ?? true, + dto.areaM2 ?? null, + dto.monthlyRentVND ? BigInt(Math.round(dto.monthlyRentVND)) : null, + dto.depositMonths ?? null, + dto.remainingLeaseMo ?? null, + dto.businessType ?? null, + dto.footTraffic ?? null, + dto.contactPhone ?? null, + dto.contactName ?? null, + (dto.items ?? []).map((item) => ({ + name: item.name, + brand: item.brand, + modelName: item.modelName, + category: item.category, + condition: item.condition, + purchaseYear: item.purchaseYear, + originalPriceVND: item.originalPriceVND != null ? BigInt(Math.round(item.originalPriceVND)) : undefined, + askingPriceVND: BigInt(Math.round(item.askingPriceVND)), + quantity: item.quantity, + dimensions: item.dimensions, + notes: item.notes, + })), + ), + ); + } + + @ApiOperation({ summary: 'Cập nhật tin sang nhượng', description: 'Cập nhật thông tin tin sang nhượng' }) + @ApiResponse({ status: 200, description: 'Tin sang nhượng đã cập nhật' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard) + @Patch('listings/:id') + async updateListing( + @Param('id') id: string, + @Body() dto: UpdateTransferListingDto, + ) { + return this.commandBus.execute( + new UpdateTransferListingCommand( + id, + dto.title, + dto.description, + dto.status, + dto.askingPriceVND ? BigInt(Math.round(dto.askingPriceVND)) : undefined, + dto.isNegotiable, + dto.areaM2, + dto.monthlyRentVND !== undefined ? BigInt(Math.round(dto.monthlyRentVND)) : undefined, + dto.depositMonths, + dto.remainingLeaseMo, + dto.businessType, + dto.footTraffic, + dto.contactPhone, + dto.contactName, + dto.media, + ), + ); + } +} diff --git a/apps/api/src/modules/transfer/presentation/dto/create-transfer-listing.dto.ts b/apps/api/src/modules/transfer/presentation/dto/create-transfer-listing.dto.ts new file mode 100644 index 0000000..11faee2 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/create-transfer-listing.dto.ts @@ -0,0 +1,169 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { TransferCategory, TransferCondition, TransferPricingSource } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class CreateTransferItemDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + name!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + brand?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + modelName?: string; + + @ApiProperty({ enum: TransferCategory }) + @IsEnum(TransferCategory) + category!: TransferCategory; + + @ApiProperty({ enum: TransferCondition }) + @IsEnum(TransferCondition) + condition!: TransferCondition; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Type(() => Number) + purchaseYear?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + originalPriceVND?: number; + + @ApiProperty() + @IsNumber() + @Type(() => Number) + askingPriceVND!: number; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Type(() => Number) + quantity?: number; + + @ApiPropertyOptional() + @IsOptional() + dimensions?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateTransferListingDto { + @ApiProperty({ enum: TransferCategory }) + @IsEnum(TransferCategory) + category!: TransferCategory; + + @ApiProperty() + @IsNotEmpty() + @IsString() + title!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + address!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + ward?: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + district!: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + city!: string; + + @ApiProperty() + @IsNumber() + latitude!: number; + + @ApiProperty() + @IsNumber() + longitude!: number; + + @ApiProperty() + @IsNumber() + @Type(() => Number) + askingPriceVND!: number; + + @ApiPropertyOptional({ enum: TransferPricingSource, default: 'MANUAL' }) + @IsOptional() + @IsEnum(TransferPricingSource) + pricingSource?: TransferPricingSource; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isNegotiable?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + areaM2?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + monthlyRentVND?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Type(() => Number) + depositMonths?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Type(() => Number) + remainingLeaseMo?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + businessType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + footTraffic?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + contactPhone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + contactName?: string; + + @ApiPropertyOptional({ type: [CreateTransferItemDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTransferItemDto) + items?: CreateTransferItemDto[]; +} diff --git a/apps/api/src/modules/transfer/presentation/dto/estimate-transfer-prices.dto.ts b/apps/api/src/modules/transfer/presentation/dto/estimate-transfer-prices.dto.ts new file mode 100644 index 0000000..12e1e64 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/estimate-transfer-prices.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { TransferCategory, TransferCondition } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; + +export class EstimateItemDto { + @ApiProperty({ enum: TransferCategory }) + @IsEnum(TransferCategory) + category!: TransferCategory; + + @ApiProperty({ enum: TransferCondition }) + @IsEnum(TransferCondition) + condition!: TransferCondition; + + @ApiProperty({ description: 'Giá mua ban đầu (VND)' }) + @IsNumber() + @Type(() => Number) + originalPriceVND!: number; + + @ApiProperty({ description: 'Năm mua' }) + @IsInt() + @Type(() => Number) + purchaseYear!: number; + + @ApiPropertyOptional({ description: 'Thương hiệu' }) + @IsOptional() + @IsString() + brand?: string; +} + +export class EstimateTransferPricesDto { + @ApiProperty({ type: [EstimateItemDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EstimateItemDto) + items!: EstimateItemDto[]; +} diff --git a/apps/api/src/modules/transfer/presentation/dto/index.ts b/apps/api/src/modules/transfer/presentation/dto/index.ts new file mode 100644 index 0000000..983e3b2 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/index.ts @@ -0,0 +1,4 @@ +export { SearchTransferListingsDto } from './search-transfer-listings.dto'; +export { CreateTransferListingDto, CreateTransferItemDto } from './create-transfer-listing.dto'; +export { UpdateTransferListingDto } from './update-transfer-listing.dto'; +export { EstimateTransferPricesDto, EstimateItemDto } from './estimate-transfer-prices.dto'; diff --git a/apps/api/src/modules/transfer/presentation/dto/search-transfer-listings.dto.ts b/apps/api/src/modules/transfer/presentation/dto/search-transfer-listings.dto.ts new file mode 100644 index 0000000..aedefc4 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/search-transfer-listings.dto.ts @@ -0,0 +1,56 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { TransferCategory, TransferListingStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class SearchTransferListingsDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + q?: string; + + @ApiPropertyOptional({ enum: TransferCategory }) + @IsOptional() + @IsEnum(TransferCategory) + category?: TransferCategory; + + @ApiPropertyOptional({ enum: TransferListingStatus }) + @IsOptional() + @IsEnum(TransferListingStatus) + status?: TransferListingStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + district?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + city?: string; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + minPrice?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + maxPrice?: number; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + limit?: number; +} diff --git a/apps/api/src/modules/transfer/presentation/dto/update-transfer-listing.dto.ts b/apps/api/src/modules/transfer/presentation/dto/update-transfer-listing.dto.ts new file mode 100644 index 0000000..2055036 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/update-transfer-listing.dto.ts @@ -0,0 +1,79 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { TransferListingStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class UpdateTransferListingDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ enum: TransferListingStatus }) + @IsOptional() + @IsEnum(TransferListingStatus) + status?: TransferListingStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + askingPriceVND?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isNegotiable?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Type(() => Number) + areaM2?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + monthlyRentVND?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Type(() => Number) + depositMonths?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + @Type(() => Number) + remainingLeaseMo?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + businessType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + footTraffic?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + contactPhone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + contactName?: string; + + @ApiPropertyOptional() + @IsOptional() + media?: Record[]; +} diff --git a/apps/api/src/modules/transfer/transfer.module.ts b/apps/api/src/modules/transfer/transfer.module.ts new file mode 100644 index 0000000..aecdc67 --- /dev/null +++ b/apps/api/src/modules/transfer/transfer.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { SearchModule } from '@modules/search'; +import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler'; +import { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.handler'; +import { UpdateTransferListingHandler } from './application/commands/update-transfer-listing/update-transfer-listing.handler'; +import { GetTransferListingHandler } from './application/queries/get-transfer-listing/get-transfer-listing.handler'; +import { ListTransferListingsHandler } from './application/queries/list-transfer-listings/list-transfer-listings.handler'; +import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler'; +import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository'; +import { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-transfer-listing.repository'; +import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service'; +import { TransferController } from './presentation/controllers/transfer.controller'; + +const CommandHandlers = [ + CreateTransferListingHandler, + EstimateTransferPricesHandler, + UpdateTransferListingHandler, +]; + +const QueryHandlers = [ + GetTransferListingHandler, + ListTransferListingsHandler, + TransferStatsHandler, +]; + +@Module({ + imports: [CqrsModule, SearchModule], + controllers: [TransferController], + providers: [ + { provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository }, + TypesenseTransferService, + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [TRANSFER_LISTING_REPOSITORY, TypesenseTransferService], +}) +export class TransferModule {} diff --git a/docker-compose.yml b/docker-compose.yml index 94b045f..16433eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,11 +26,11 @@ services: restart: unless-stopped ports: - '${REDIS_PORT:-6379}:6379' - command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-changeme} volumes: - redis_data:/data healthcheck: - test: ['CMD', 'redis-cli', 'ping'] + test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-changeme}', '--no-auth-warning', 'ping'] interval: 10s timeout: 5s retries: 5 @@ -70,6 +70,7 @@ services: environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_API_CORS_ALLOW_ORIGIN: 'http://localhost:3000,http://localhost:3001' volumes: - minio_data:/data healthcheck: @@ -81,6 +82,28 @@ services: networks: - goodgo-net + minio-init: + image: minio/mc:latest + container_name: goodgo-minio-init + restart: 'no' + depends_on: + minio: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - | + mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} + mc mb --ignore-existing local/$${MINIO_BUCKET} + mc anonymous set download local/$${MINIO_BUCKET} + echo "MinIO init complete: bucket=$${MINIO_BUCKET}, public read enabled" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_BUCKET: ${MINIO_BUCKET:-goodgo-media} + networks: + - goodgo-net + ai-services: build: context: ./libs/ai-services diff --git a/package.json b/package.json index 2b04ed9..880cd31 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "@prisma/client", "@prisma/engines", "esbuild", - "prisma" + "prisma", + "puppeteer" ], "overrides": { "axios": ">=1.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9bba51..6d345e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: apps/api: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.89.0 + version: 0.89.0(zod@4.3.6) '@aws-sdk/client-s3': specifier: ^3.1026.0 version: 3.1026.0 @@ -84,6 +87,9 @@ importers: '@goodgo/mcp-servers': specifier: workspace:* version: link:../../libs/mcp-servers + '@nestjs/bullmq': + specifier: ^11.0.4 + version: 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.74.1) '@nestjs/common': specifier: ^11.0.0 version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -147,6 +153,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + bullmq: + specifier: ^5.74.1 + version: 5.74.1 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -198,6 +207,9 @@ importers: prom-client: specifier: ^15.1.3 version: 15.1.3 + puppeteer: + specifier: ^24.41.0 + version: 24.41.0(typescript@6.0.2) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -448,6 +460,15 @@ packages: resolution: {integrity: sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@anthropic-ai/sdk@0.89.0': + resolution: {integrity: sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.8': resolution: {integrity: sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1449,6 +1470,36 @@ packages: '@cfworker/json-schema': optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@mswjs/interceptors@0.41.3': resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} @@ -1456,6 +1507,19 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/bull-shared@11.0.4': + resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/bullmq@11.0.4': + resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nestjs/cli@11.0.18': resolution: {integrity: sha512-z72OS+sFrDgIkNu/e/vUhbnjHZwAYQS8fBJKXLiFyz8059IVuY2FKebV2YMxyhY+920d4LX1hBIAGL5qQNdR7g==} engines: {node: '>= 20.11'} @@ -2176,6 +2240,11 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@puppeteer/browsers@2.13.0': + resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -2952,6 +3021,9 @@ packages: resolution: {integrity: sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==} engines: {node: '>= 10'} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@turbo/darwin-64@2.9.4': resolution: {integrity: sha512-ZSlPqJ5Vqg/wgVw8P3AOVCIosnbBilOxLq7TMz3MN/9U46DUYfdG2jtfevNDufyxyrg98pcPs/GBgDRaaids6g==} cpu: [x64] @@ -3212,6 +3284,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.58.0': resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3614,6 +3689,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -3638,6 +3717,14 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3645,6 +3732,47 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.8.7: + resolution: {integrity: sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.0: + resolution: {integrity: sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.0: + resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -3665,6 +3793,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.3.0: + resolution: {integrity: sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==} + engines: {node: '>=10.0.0'} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -3715,6 +3847,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3724,6 +3859,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bullmq@5.74.1: + resolution: {integrity: sha512-GfJEos2zoOGM9xqkB7VZouwwFuejKFqm667cBcmbBekJXKqqXWk4QYP3Uy2pzgUwCbg1cR7GgGmGczM7fnhWSA==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -3805,6 +3943,11 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromium-bidi@14.0.0: + resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} + peerDependencies: + devtools-protocol: '*' + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -3975,6 +4118,19 @@ packages: typescript: optional: true + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} @@ -4052,6 +4208,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4095,6 +4255,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -4123,6 +4287,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devtools-protocol@0.0.1595872: + resolution: {integrity: sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -4235,6 +4402,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4287,6 +4458,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -4407,6 +4583,9 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4439,6 +4618,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + farmhash-modern@1.1.0: resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} engines: {node: '>=18.0.0'} @@ -4453,6 +4637,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4490,6 +4677,9 @@ packages: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4659,9 +4849,17 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -4800,6 +4998,10 @@ packages: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} @@ -5024,6 +5226,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5185,6 +5391,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lru-memoizer@2.3.0: resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} @@ -5307,12 +5517,22 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + msw@2.13.2: resolution: {integrity: sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==} engines: {node: '>=18'} @@ -5369,6 +5589,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + next-intl-swc-plugin-extractor@4.9.0: resolution: {integrity: sha512-CAu6Qy6XiCenKsvzyCPm2cZFkGfcvhJi8N93TCnOowmzD4Br3ked7QdROusRRp4MQ1iG9u+KCLgVcM9CLDUOIQ==} @@ -5445,6 +5669,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -5539,6 +5767,14 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -5622,6 +5858,9 @@ packages: resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} hasBin: true + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -5864,6 +6103,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -5878,6 +6121,15 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + puppeteer-core@24.41.0: + resolution: {integrity: sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==} + engines: {node: '>=18'} + + puppeteer@24.41.0: + resolution: {integrity: sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==} + engines: {node: '>=18'} + hasBin: true + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -6211,6 +6463,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} @@ -6226,6 +6482,14 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -6293,6 +6557,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -6431,6 +6698,12 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@3.1.8: + resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -6438,6 +6711,9 @@ packages: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} engines: {node: '>=14'} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -6459,6 +6735,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} @@ -6528,6 +6807,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -6581,6 +6863,9 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typed-query-selector@2.12.1: + resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -6808,6 +7093,9 @@ packages: web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webdriver-bidi-protocol@0.4.1: + resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -6903,6 +7191,18 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -6952,6 +7252,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -7031,6 +7334,12 @@ snapshots: transitivePeerDependencies: - chokidar + '@anthropic-ai/sdk@0.89.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@asamuzakjp/css-color@5.1.8': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -8286,6 +8595,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -8302,6 +8629,20 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(bullmq@5.74.1)': + dependencies: + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(@nestjs/websockets@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.74.1 + tslib: 2.8.1 + '@nestjs/cli@11.0.18(@swc/core@1.15.24)(@types/node@25.5.2)': dependencies: '@angular-devkit/core': 19.2.23(chokidar@4.0.3) @@ -9076,6 +9417,21 @@ snapshots: '@protobufjs/utf8@1.1.0': optional: true + '@puppeteer/browsers@2.13.0': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.4 + tar-fs: 3.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': @@ -9938,6 +10294,8 @@ snapshots: '@tootallnate/once@3.0.1': optional: true + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@turbo/darwin-64@2.9.4': optional: true @@ -10241,6 +10599,11 @@ snapshots: dependencies: '@types/node': 25.5.2 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.5.2 + optional: true + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -10669,6 +11032,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -10697,10 +11064,44 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.8.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.0(bare-events@2.8.2) + bare-url: 2.4.0 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.8.7: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.8.7 + + bare-stream@2.13.0(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.0: + dependencies: + bare-path: 3.0.0 + base64-arraybuffer@1.0.2: {} base64-js@1.5.1: {} @@ -10711,6 +11112,8 @@ snapshots: baseline-browser-mapping@2.10.16: {} + basic-ftp@5.3.0: {} + bcrypt@6.0.0: dependencies: node-addon-api: 8.7.0 @@ -10782,6 +11185,8 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -10791,6 +11196,18 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.74.1: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -10879,6 +11296,12 @@ snapshots: chrome-trace-event@1.0.4: {} + chromium-bidi@14.0.0(devtools-protocol@0.0.1595872): + dependencies: + devtools-protocol: 0.0.1595872 + mitt: 3.0.1 + zod: 3.25.76 + citty@0.1.6: dependencies: consola: 3.4.2 @@ -11024,6 +11447,19 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cosmiconfig@9.0.1(typescript@6.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.2 + + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cron@4.4.0: dependencies: '@types/luxon': 3.7.1 @@ -11092,6 +11528,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 @@ -11123,6 +11561,12 @@ snapshots: defu@6.1.7: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -11156,6 +11600,8 @@ snapshots: detect-libc@2.1.2: {} + devtools-protocol@0.0.1595872: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -11285,6 +11731,8 @@ snapshots: entities@7.0.1: {} + env-paths@2.2.1: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -11349,6 +11797,14 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -11487,6 +11943,12 @@ snapshots: eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -11539,6 +12001,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + farmhash-modern@1.1.0: {} fast-check@3.23.2: @@ -11549,6 +12021,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -11596,6 +12070,10 @@ snapshots: dependencies: websocket-driver: 0.7.4 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -11820,10 +12298,22 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.0 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + giget@2.0.0: dependencies: citty: 0.1.6 @@ -11997,6 +12487,13 @@ snapshots: - supports-color optional: true + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-status-codes@2.3.0: {} https-proxy-agent@5.0.1: @@ -12208,6 +12705,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -12380,6 +12882,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru-cache@7.18.3: {} + lru-memoizer@2.3.0: dependencies: lodash.clonedeep: 4.5.0 @@ -12496,10 +13000,28 @@ snapshots: minipass@7.1.3: {} + mitt@3.0.1: {} + module-details-from-path@1.0.4: {} ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2): dependencies: '@inquirer/confirm': 5.1.21(@types/node@25.5.2) @@ -12570,6 +13092,8 @@ snapshots: neo-async@2.6.2: {} + netmask@2.1.1: {} + next-intl-swc-plugin-extractor@4.9.0: {} next-intl@4.9.0(next@15.5.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@6.0.2): @@ -12644,6 +13168,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: {} node-releases@2.0.37: {} @@ -12738,6 +13267,24 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + pako@2.1.0: {} parent-module@1.0.1: @@ -12815,6 +13362,8 @@ snapshots: dependencies: resolve-protobuf-schema: 2.1.0 + pend@1.2.0: {} + perfect-debounce@1.0.0: {} performance-now@2.1.0: @@ -13065,6 +13614,19 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-from-env@1.1.0: {} proxy-from-env@2.1.0: {} @@ -13076,6 +13638,40 @@ snapshots: punycode@2.3.1: {} + puppeteer-core@24.41.0: + dependencies: + '@puppeteer/browsers': 2.13.0 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872) + debug: 4.4.3 + devtools-protocol: 0.0.1595872 + typed-query-selector: 2.12.1 + webdriver-bidi-protocol: 0.4.1 + ws: 8.20.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.41.0(typescript@6.0.2): + dependencies: + '@puppeteer/browsers': 2.13.0 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872) + cosmiconfig: 9.0.1(typescript@6.0.2) + devtools-protocol: 0.0.1595872 + puppeteer-core: 24.41.0 + typed-query-selector: 2.12.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + pure-rand@6.1.0: {} qrcode@1.5.4: @@ -13489,6 +14085,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} + socket.io-adapter@2.5.6: dependencies: debug: 4.4.3 @@ -13530,6 +14128,19 @@ snapshots: - supports-color - utf-8-validate + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -13580,6 +14191,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -13739,6 +14359,29 @@ snapshots: tapable@2.3.2: {} + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.1.8 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.8: + dependencies: + b4a: 1.8.0 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tdigest@0.1.2: dependencies: bintrees: 1.0.2 @@ -13755,6 +14398,13 @@ snapshots: - supports-color optional: true + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(webpack@5.105.4(@swc/core@1.15.24)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -13780,6 +14430,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + text-segmentation@1.0.3: dependencies: utrie: 1.0.2 @@ -13844,6 +14500,8 @@ snapshots: dependencies: punycode: 2.3.1 + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.2): dependencies: typescript: 6.0.2 @@ -13904,6 +14562,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typed-query-selector@2.12.1: {} + typedarray@0.0.6: {} typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2): @@ -14146,6 +14806,8 @@ snapshots: web-vitals@5.2.0: {} + webdriver-bidi-protocol@0.4.1: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -14282,6 +14944,8 @@ snapshots: ws@8.18.3: {} + ws@8.20.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -14331,6 +14995,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} diff --git a/prisma/migrations/20260416300000_add_industrial_parks/migration.sql b/prisma/migrations/20260416300000_add_industrial_parks/migration.sql new file mode 100644 index 0000000..715ba28 --- /dev/null +++ b/prisma/migrations/20260416300000_add_industrial_parks/migration.sql @@ -0,0 +1,168 @@ +-- CreateEnum +CREATE TYPE "IndustrialParkStatus" AS ENUM ('PLANNING', 'UNDER_CONSTRUCTION', 'OPERATIONAL', 'FULL'); + +-- CreateEnum +CREATE TYPE "IndustrialPropertyType" AS ENUM ('INDUSTRIAL_LAND', 'READY_BUILT_FACTORY', 'READY_BUILT_WAREHOUSE', 'LOGISTICS_CENTER', 'OFFICE_IN_PARK', 'DATA_CENTER'); + +-- CreateEnum +CREATE TYPE "IndustrialLeaseType" AS ENUM ('LAND_LEASE', 'FACTORY_LEASE', 'WAREHOUSE_LEASE', 'SUBLEASE'); + +-- CreateEnum +CREATE TYPE "IndustrialListingStatus" AS ENUM ('DRAFT', 'ACTIVE', 'RESERVED', 'LEASED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "VietnamRegion" AS ENUM ('NORTH', 'CENTRAL', 'SOUTH'); + +-- CreateTable +CREATE TABLE "IndustrialPark" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEn" TEXT, + "slug" TEXT NOT NULL, + "developer" TEXT NOT NULL, + "operator" TEXT, + "status" "IndustrialParkStatus" NOT NULL DEFAULT 'PLANNING', + "location" geometry(Point, 4326) NOT NULL, + "address" TEXT NOT NULL, + "district" TEXT NOT NULL, + "province" TEXT NOT NULL, + "region" "VietnamRegion" NOT NULL, + "totalAreaHa" DOUBLE PRECISION NOT NULL, + "leasableAreaHa" DOUBLE PRECISION NOT NULL, + "occupancyRate" DOUBLE PRECISION NOT NULL DEFAULT 0, + "remainingAreaHa" DOUBLE PRECISION NOT NULL, + "tenantCount" INTEGER NOT NULL DEFAULT 0, + "establishedYear" INTEGER, + "landRentUsdM2Year" DOUBLE PRECISION, + "rbfRentUsdM2Month" DOUBLE PRECISION, + "rbwRentUsdM2Month" DOUBLE PRECISION, + "managementFeeUsd" DOUBLE PRECISION, + "infrastructure" JSONB, + "connectivity" JSONB, + "incentives" JSONB, + "targetIndustries" TEXT[], + "existingTenants" JSONB, + "certifications" JSONB, + "media" JSONB, + "documents" JSONB, + "description" TEXT, + "descriptionEn" TEXT, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IndustrialPark_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IndustrialListing" ( + "id" TEXT NOT NULL, + "parkId" TEXT NOT NULL, + "agentId" TEXT, + "sellerId" TEXT NOT NULL, + "propertyType" "IndustrialPropertyType" NOT NULL, + "leaseType" "IndustrialLeaseType" NOT NULL, + "status" "IndustrialListingStatus" NOT NULL DEFAULT 'DRAFT', + "title" TEXT NOT NULL, + "description" TEXT, + "areaM2" DOUBLE PRECISION NOT NULL, + "ceilingHeightM" DOUBLE PRECISION, + "floorLoadTonM2" DOUBLE PRECISION, + "columnSpacingM" DOUBLE PRECISION, + "dockCount" INTEGER, + "craneCapacityTon" DOUBLE PRECISION, + "hasMezzanine" BOOLEAN NOT NULL DEFAULT false, + "hasOfficeArea" BOOLEAN NOT NULL DEFAULT false, + "officeAreaM2" DOUBLE PRECISION, + "priceUsdM2" DOUBLE PRECISION, + "pricingUnit" TEXT, + "totalLeasePrice" DOUBLE PRECISION, + "managementFee" DOUBLE PRECISION, + "depositMonths" INTEGER, + "minLeaseYears" INTEGER, + "maxLeaseYears" INTEGER, + "leaseExpiry" TIMESTAMP(3), + "availableFrom" TIMESTAMP(3), + "powerCapacityKva" DOUBLE PRECISION, + "waterSupplyM3Day" DOUBLE PRECISION, + "media" JSONB, + "viewCount" INTEGER NOT NULL DEFAULT 0, + "inquiryCount" INTEGER NOT NULL DEFAULT 0, + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IndustrialListing_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "IndustrialPark_slug_key" ON "IndustrialPark"("slug"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_status_idx" ON "IndustrialPark"("status"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_province_idx" ON "IndustrialPark"("province"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_region_idx" ON "IndustrialPark"("region"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_developer_idx" ON "IndustrialPark"("developer"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_location_idx" ON "IndustrialPark" USING GIST ("location"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_isVerified_idx" ON "IndustrialPark"("isVerified"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_occupancyRate_idx" ON "IndustrialPark"("occupancyRate"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_landRentUsdM2Year_idx" ON "IndustrialPark"("landRentUsdM2Year"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_region_province_status_idx" ON "IndustrialPark"("region", "province", "status"); + +-- CreateIndex +CREATE INDEX "IndustrialPark_createdAt_idx" ON "IndustrialPark"("createdAt"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_parkId_idx" ON "IndustrialListing"("parkId"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_propertyType_idx" ON "IndustrialListing"("propertyType"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_leaseType_idx" ON "IndustrialListing"("leaseType"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_status_idx" ON "IndustrialListing"("status"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_areaM2_idx" ON "IndustrialListing"("areaM2"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_priceUsdM2_idx" ON "IndustrialListing"("priceUsdM2"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_sellerId_idx" ON "IndustrialListing"("sellerId"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_agentId_idx" ON "IndustrialListing"("agentId"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_publishedAt_idx" ON "IndustrialListing"("publishedAt"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_parkId_status_idx" ON "IndustrialListing"("parkId", "status"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_propertyType_leaseType_status_idx" ON "IndustrialListing"("propertyType", "leaseType", "status"); + +-- CreateIndex +CREATE INDEX "IndustrialListing_status_publishedAt_idx" ON "IndustrialListing"("status", "publishedAt" DESC); + +-- AddForeignKey +ALTER TABLE "IndustrialListing" ADD CONSTRAINT "IndustrialListing_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "IndustrialPark"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260416400000_add_transfer_listings/migration.sql b/prisma/migrations/20260416400000_add_transfer_listings/migration.sql new file mode 100644 index 0000000..67dd009 --- /dev/null +++ b/prisma/migrations/20260416400000_add_transfer_listings/migration.sql @@ -0,0 +1,105 @@ +-- CreateEnum +CREATE TYPE "TransferCategory" AS ENUM ('FURNITURE', 'APPLIANCE', 'OFFICE_EQUIPMENT', 'KITCHEN', 'PREMISES', 'FULL_UNIT'); + +-- CreateEnum +CREATE TYPE "TransferCondition" AS ENUM ('NEW', 'LIKE_NEW', 'GOOD', 'FAIR', 'WORN'); + +-- CreateEnum +CREATE TYPE "TransferListingStatus" AS ENUM ('DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'RESERVED', 'SOLD', 'EXPIRED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "TransferPricingSource" AS ENUM ('MANUAL', 'AI_ESTIMATED', 'NEGOTIABLE'); + +-- CreateTable +CREATE TABLE "TransferListing" ( + "id" TEXT NOT NULL, + "sellerId" TEXT NOT NULL, + "category" "TransferCategory" NOT NULL, + "status" "TransferListingStatus" NOT NULL DEFAULT 'DRAFT', + "title" TEXT NOT NULL, + "description" TEXT, + "address" TEXT NOT NULL, + "ward" TEXT, + "district" TEXT NOT NULL, + "city" TEXT NOT NULL, + "location" geometry(Point, 4326) NOT NULL, + "askingPriceVND" BIGINT NOT NULL, + "aiEstimatePriceVND" BIGINT, + "aiConfidence" DOUBLE PRECISION, + "pricingSource" "TransferPricingSource" NOT NULL DEFAULT 'MANUAL', + "isNegotiable" BOOLEAN NOT NULL DEFAULT true, + "areaM2" DOUBLE PRECISION, + "monthlyRentVND" BIGINT, + "depositMonths" INTEGER, + "remainingLeaseMo" INTEGER, + "businessType" TEXT, + "footTraffic" TEXT, + "media" JSONB, + "moderationScore" DOUBLE PRECISION, + "moderationNotes" TEXT, + "viewCount" INTEGER NOT NULL DEFAULT 0, + "saveCount" INTEGER NOT NULL DEFAULT 0, + "inquiryCount" INTEGER NOT NULL DEFAULT 0, + "contactPhone" TEXT, + "contactName" TEXT, + "featuredUntil" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TransferListing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TransferItem" ( + "id" TEXT NOT NULL, + "transferListingId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "brand" TEXT, + "modelName" TEXT, + "category" "TransferCategory" NOT NULL, + "condition" "TransferCondition" NOT NULL, + "purchaseYear" INTEGER, + "originalPriceVND" BIGINT, + "askingPriceVND" BIGINT NOT NULL, + "aiEstimatePriceVND" BIGINT, + "aiConfidence" DOUBLE PRECISION, + "quantity" INTEGER NOT NULL DEFAULT 1, + "dimensions" JSONB, + "media" JSONB, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TransferItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: TransferListing +CREATE INDEX "TransferListing_sellerId_idx" ON "TransferListing"("sellerId"); +CREATE INDEX "TransferListing_category_idx" ON "TransferListing"("category"); +CREATE INDEX "TransferListing_status_idx" ON "TransferListing"("status"); +CREATE INDEX "TransferListing_district_city_idx" ON "TransferListing"("district", "city"); +CREATE INDEX "TransferListing_askingPriceVND_idx" ON "TransferListing"("askingPriceVND"); +CREATE INDEX "TransferListing_location_idx" ON "TransferListing" USING GIST ("location"); +CREATE INDEX "TransferListing_publishedAt_idx" ON "TransferListing"("publishedAt"); +CREATE INDEX "TransferListing_createdAt_idx" ON "TransferListing"("createdAt"); +CREATE INDEX "TransferListing_featuredUntil_idx" ON "TransferListing"("featuredUntil"); +CREATE INDEX "TransferListing_expiresAt_idx" ON "TransferListing"("expiresAt"); +CREATE INDEX "TransferListing_category_status_publishedAt_idx" ON "TransferListing"("category", "status", "publishedAt" DESC); +CREATE INDEX "TransferListing_district_city_category_status_idx" ON "TransferListing"("district", "city", "category", "status"); +CREATE INDEX "TransferListing_status_createdAt_idx" ON "TransferListing"("status", "createdAt" DESC); + +-- CreateIndex: TransferItem +CREATE INDEX "TransferItem_transferListingId_idx" ON "TransferItem"("transferListingId"); +CREATE INDEX "TransferItem_category_idx" ON "TransferItem"("category"); +CREATE INDEX "TransferItem_condition_idx" ON "TransferItem"("condition"); +CREATE INDEX "TransferItem_brand_idx" ON "TransferItem"("brand"); +CREATE INDEX "TransferItem_askingPriceVND_idx" ON "TransferItem"("askingPriceVND"); +CREATE INDEX "TransferItem_transferListingId_category_idx" ON "TransferItem"("transferListingId", "category"); + +-- AddForeignKey +ALTER TABLE "TransferListing" ADD CONSTRAINT "TransferListing_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TransferItem" ADD CONSTRAINT "TransferItem_transferListingId_fkey" FOREIGN KEY ("transferListingId") REFERENCES "TransferListing"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260416500000_add_ai_reports/migration.sql b/prisma/migrations/20260416500000_add_ai_reports/migration.sql new file mode 100644 index 0000000..d28a7fd --- /dev/null +++ b/prisma/migrations/20260416500000_add_ai_reports/migration.sql @@ -0,0 +1,77 @@ +-- CreateEnum +CREATE TYPE "ReportType" AS ENUM ('RESIDENTIAL_MARKET', 'INDUSTRIAL_MARKET', 'DISTRICT_ANALYSIS', 'INVESTMENT_FEASIBILITY', 'INDUSTRIAL_LOCATION', 'PROPERTY_VALUATION', 'PORTFOLIO'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('GENERATING', 'READY', 'FAILED'); + +-- AlterTable: Add maxReports to Plan +ALTER TABLE "Plan" ADD COLUMN "maxReports" INTEGER; + +-- CreateTable: Report +CREATE TABLE "Report" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "ReportType" NOT NULL, + "title" TEXT NOT NULL, + "params" JSONB NOT NULL, + "content" JSONB, + "pdfUrl" TEXT, + "status" "ReportStatus" NOT NULL DEFAULT 'GENERATING', + "errorMsg" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Report_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: MacroeconomicData +CREATE TABLE "MacroeconomicData" ( + "id" TEXT NOT NULL, + "province" TEXT NOT NULL, + "indicator" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "period" TEXT NOT NULL, + "source" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MacroeconomicData_pkey" PRIMARY KEY ("id") +); + +-- CreateTable: InfrastructureProject +CREATE TABLE "InfrastructureProject" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "province" TEXT NOT NULL, + "category" TEXT NOT NULL, + "status" TEXT NOT NULL, + "investmentVND" BIGINT, + "startDate" TIMESTAMP(3), + "completionDate" TIMESTAMP(3), + "description" TEXT, + "impactRadius" DOUBLE PRECISION, + "location" geometry(Point, 4326), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InfrastructureProject_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Report_userId_createdAt_idx" ON "Report"("userId", "createdAt" DESC); +CREATE INDEX "Report_userId_type_idx" ON "Report"("userId", "type"); +CREATE INDEX "Report_status_idx" ON "Report"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "MacroeconomicData_province_indicator_period_key" ON "MacroeconomicData"("province", "indicator", "period"); +CREATE INDEX "MacroeconomicData_province_idx" ON "MacroeconomicData"("province"); +CREATE INDEX "MacroeconomicData_indicator_period_idx" ON "MacroeconomicData"("indicator", "period"); + +-- CreateIndex +CREATE INDEX "InfrastructureProject_province_idx" ON "InfrastructureProject"("province"); +CREATE INDEX "InfrastructureProject_category_idx" ON "InfrastructureProject"("category"); +CREATE INDEX "InfrastructureProject_status_idx" ON "InfrastructureProject"("status"); +CREATE INDEX "InfrastructureProject_province_category_idx" ON "InfrastructureProject"("province", "category"); + +-- AddForeignKey +ALTER TABLE "Report" ADD CONSTRAINT "Report_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0ed8726..1d0b85a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model User { buyerOrders Order[] @relation("BuyerOrders") sellerOrders Order[] @relation("SellerOrders") mfaChallenges MfaChallenge[] + transferListings TransferListing[] + reports Report[] @@index([role]) @@index([kycStatus]) @@ -623,6 +625,7 @@ model Plan { maxListings Int? maxSavedSearches Int? maxAnalyticsQueries Int? + maxReports Int? maxMediaUploads Int? features Json isActive Boolean @default(true) @@ -1089,3 +1092,203 @@ model Message { @@index([conversationId, createdAt]) @@index([senderId]) } + +// ============================================================================= +// TRANSFER (Furniture + Premises Handover) +// ============================================================================= + +enum TransferCategory { + FURNITURE // Nội thất (sofa, bàn, tủ, giường) + APPLIANCE // Thiết bị gia dụng (máy lạnh, tủ lạnh, máy giặt) + OFFICE_EQUIPMENT // Thiết bị văn phòng (bàn làm việc, ghế, máy in) + KITCHEN // Bếp + thiết bị bếp + PREMISES // Mặt bằng kinh doanh + FULL_UNIT // Chuyển nhượng trọn bộ (nội thất + mặt bằng) +} + +enum TransferCondition { + NEW // Mới (< 6 tháng) + LIKE_NEW // Như mới (6-12 tháng) + GOOD // Tốt (1-3 năm) + FAIR // Khá (3-5 năm) + WORN // Cũ (> 5 năm) +} + +enum TransferListingStatus { + DRAFT + PENDING_REVIEW + ACTIVE + RESERVED + SOLD + EXPIRED + REJECTED +} + +enum TransferPricingSource { + MANUAL // Người bán tự định giá + AI_ESTIMATED // AI ước tính dựa trên khấu hao + thương hiệu + NEGOTIABLE // Giá thương lượng +} + +model TransferListing { + id String @id @default(cuid()) + sellerId String + seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict) + category TransferCategory + status TransferListingStatus @default(DRAFT) + title String + description String? @db.Text + // Location + address String + ward String? + district String + city String + location Unsupported("geometry(Point, 4326)") + // Pricing + askingPriceVND BigInt + aiEstimatePriceVND BigInt? + aiConfidence Float? + pricingSource TransferPricingSource @default(MANUAL) + isNegotiable Boolean @default(true) + // Premises-specific fields (for PREMISES / FULL_UNIT) + areaM2 Float? + monthlyRentVND BigInt? + depositMonths Int? + remainingLeaseMo Int? + businessType String? // Loại hình kinh doanh hiện tại + footTraffic String? // Mô tả lưu lượng khách + // Metadata + media Json? // [{ url, type, order, caption }] + moderationScore Float? + moderationNotes String? + viewCount Int @default(0) + saveCount Int @default(0) + inquiryCount Int @default(0) + contactPhone String? + contactName String? + featuredUntil DateTime? + expiresAt DateTime? + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items TransferItem[] + + @@index([sellerId]) + @@index([category]) + @@index([status]) + @@index([district, city]) + @@index([askingPriceVND]) + @@index([location], type: Gist) + @@index([publishedAt]) + @@index([createdAt]) + @@index([featuredUntil]) + @@index([expiresAt]) + @@index([category, status, publishedAt(sort: Desc)]) + @@index([district, city, category, status]) + @@index([status, createdAt(sort: Desc)]) +} + +model TransferItem { + id String @id @default(cuid()) + transferListingId String + transferListing TransferListing @relation(fields: [transferListingId], references: [id], onDelete: Cascade) + name String // Tên sản phẩm (e.g. "Sofa góc L 3m") + brand String? // Thương hiệu + modelName String? // Model / SKU + category TransferCategory + condition TransferCondition + purchaseYear Int? // Năm mua + originalPriceVND BigInt? // Giá mua ban đầu + askingPriceVND BigInt // Giá bán mong muốn + aiEstimatePriceVND BigInt? // AI ước tính + aiConfidence Float? + quantity Int @default(1) + dimensions Json? // { widthCm, heightCm, depthCm, weightKg } + media Json? // [{ url, type, order }] + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([transferListingId]) + @@index([category]) + @@index([condition]) + @@index([brand]) + @@index([askingPriceVND]) + @@index([transferListingId, category]) +} + +// ============================================================================= +// AI REPORTS +// ============================================================================= + +enum ReportType { + RESIDENTIAL_MARKET + INDUSTRIAL_MARKET + DISTRICT_ANALYSIS + INVESTMENT_FEASIBILITY + INDUSTRIAL_LOCATION + PROPERTY_VALUATION + PORTFOLIO +} + +enum ReportStatus { + GENERATING + READY + FAILED +} + +model Report { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type ReportType + title String + params Json // Input parameters (city, province, period, etc.) + content Json? // Structured report content (sections, charts data) + pdfUrl String? // MinIO URL to generated PDF + status ReportStatus @default(GENERATING) + errorMsg String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, createdAt(sort: Desc)]) + @@index([userId, type]) + @@index([status]) +} + +model MacroeconomicData { + id String @id @default(cuid()) + province String + indicator String // gdp, fdi, population, urbanization, labor_force, avg_wage, industrial_output, cpi, mortgage_rate + value Float + unit String // USD, VND, %, persons, etc. + period String // e.g. "2025", "2025-Q4" + source String // GSO, World Bank, SBV + createdAt DateTime @default(now()) + + @@unique([province, indicator, period]) + @@index([province]) + @@index([indicator, period]) +} + +model InfrastructureProject { + id String @id @default(cuid()) + name String + province String + category String // metro, highway, airport, port, bridge, industrial_zone + status String // planning, under_construction, completed + investmentVND BigInt? + startDate DateTime? + completionDate DateTime? + description String? @db.Text + impactRadius Float? // km + location Unsupported("geometry(Point, 4326)")? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([province]) + @@index([category]) + @@index([status]) + @@index([province, category]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index a3bfe9b..c218fae 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -36,6 +36,7 @@ import { import bcrypt from 'bcrypt'; import pg from 'pg'; import { importMarketData } from '../scripts/import-market-data'; +import { seedIndustrialParks } from '../scripts/seed-industrial-parks'; import { seedPlans } from '../scripts/seed-plans'; import { seedPOIs } from '../scripts/seed-pois'; @@ -748,7 +749,11 @@ async function main() { await seedPOIs(prisma); console.log(''); - // Phase 11 — Market Data + // Phase 11 — Industrial Parks (KCN) + await seedIndustrialParks(); + console.log(''); + + // Phase 12 — Market Data await importMarketData(); console.log('\n' + '━'.repeat(60)); @@ -775,6 +780,7 @@ async function main() { console.log(' Saved Searches: 4'); console.log(' Notifications: 10 + 6 prefs'); console.log(' Audit Logs: 5'); + console.log(' Industrial: 20 parks'); console.log(' Market Index: ~240 records'); console.log('\n🔐 Admin Login:'); console.log(' Phone: 0876677771'); diff --git a/scripts/seed-industrial-parks.ts b/scripts/seed-industrial-parks.ts new file mode 100644 index 0000000..fdf4288 --- /dev/null +++ b/scripts/seed-industrial-parks.ts @@ -0,0 +1,855 @@ +/** + * Seed industrial parks (KCN) — 20 real Vietnamese parks. + * + * Usage: npx tsx scripts/seed-industrial-parks.ts + * Idempotent: uses upsert on slug unique constraint. + */ + +import { PrismaPg } from '@prisma/adapter-pg'; +import { + PrismaClient, + IndustrialParkStatus, + VietnamRegion, +} from '@prisma/client'; +import pg from 'pg'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +interface IndustrialParkSeed { + id: string; + name: string; + nameEn: string; + slug: string; + developer: string; + operator: string | null; + status: IndustrialParkStatus; + lat: number; + lng: number; + address: string; + district: string; + province: string; + region: VietnamRegion; + totalAreaHa: number; + leasableAreaHa: number; + occupancyRate: number; + remainingAreaHa: number; + tenantCount: number; + establishedYear: number; + landRentUsdM2Year: number | null; + rbfRentUsdM2Month: number | null; + rbwRentUsdM2Month: number | null; + managementFeeUsd: number | null; + infrastructure: Record; + connectivity: Record; + incentives: Record; + targetIndustries: string[]; + existingTenants: { name: string; country: string; industry: string }[]; + certifications: string[]; + description: string; + descriptionEn: string; +} + +const PARKS: IndustrialParkSeed[] = [ + { + id: 'seed-kcn-001', + name: 'KCN VSIP Bắc Ninh', + nameEn: 'VSIP Bac Ninh Industrial Park', + slug: 'vsip-bac-ninh', + developer: 'Vietnam Singapore Industrial Park', + operator: 'VSIP Group', + status: IndustrialParkStatus.OPERATIONAL, + lat: 21.1215, + lng: 106.0763, + address: 'Phường Phù Chẩn, TP Từ Sơn', + district: 'Từ Sơn', + province: 'Bắc Ninh', + region: VietnamRegion.NORTH, + totalAreaHa: 700, + leasableAreaHa: 500, + occupancyRate: 92, + remainingAreaHa: 40, + tenantCount: 250, + establishedYear: 2007, + landRentUsdM2Year: 90, + rbfRentUsdM2Month: 5.5, + rbwRentUsdM2Month: 4.8, + managementFeeUsd: 0.7, + infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 110 }, airport: { name: 'Nội Bài', distanceKm: 35 }, highway: { name: 'QL 1A', distanceKm: 5 }, railway: { name: 'Ga Bắc Ninh', distanceKm: 8 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 3 năm đầu', specialZone: false }, + targetIndustries: ['electronics', 'automotive', 'precision engineering', 'food processing'], + existingTenants: [ + { name: 'Samsung Electronics', country: 'Korea', industry: 'electronics' }, + { name: 'Canon Vietnam', country: 'Japan', industry: 'electronics' }, + { name: 'Foxconn', country: 'Taiwan', industry: 'electronics' }, + ], + certifications: ['ISO 14001', 'Green Industrial Park'], + description: 'KCN VSIP Bắc Ninh là khu công nghiệp liên doanh Việt Nam - Singapore, tọa lạc tại vị trí chiến lược gần Hà Nội. Với hạ tầng đồng bộ và dịch vụ chuyên nghiệp, đây là điểm đến hàng đầu cho các nhà đầu tư FDI.', + descriptionEn: 'VSIP Bac Ninh is a Vietnam-Singapore joint venture industrial park, strategically located near Hanoi. With synchronized infrastructure and professional services, it is a top destination for FDI investors.', + }, + { + id: 'seed-kcn-002', + name: 'KCN VSIP Bình Dương I', + nameEn: 'VSIP Binh Duong I Industrial Park', + slug: 'vsip-binh-duong-1', + developer: 'Vietnam Singapore Industrial Park', + operator: 'VSIP Group', + status: IndustrialParkStatus.FULL, + lat: 11.0174, + lng: 106.6094, + address: 'Phường An Phú, TP Thuận An', + district: 'Thuận An', + province: 'Bình Dương', + region: VietnamRegion.SOUTH, + totalAreaHa: 500, + leasableAreaHa: 355, + occupancyRate: 100, + remainingAreaHa: 0, + tenantCount: 380, + establishedYear: 1996, + landRentUsdM2Year: 110, + rbfRentUsdM2Month: 6.0, + rbwRentUsdM2Month: 5.2, + managementFeeUsd: 0.8, + infrastructure: { electricity: '110kV/22kV', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 25 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 20 }, highway: { name: 'ĐL Mỹ Phước - Tân Vạn', distanceKm: 2 }, railway: { name: 'Ga Sóng Thần', distanceKm: 5 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'N/A (đã lấp đầy)', specialZone: false }, + targetIndustries: ['electronics', 'garment', 'food processing', 'logistics'], + existingTenants: [ + { name: 'Lego Manufacturing', country: 'Denmark', industry: 'consumer goods' }, + { name: 'Pepsi Vietnam', country: 'USA', industry: 'food processing' }, + ], + certifications: ['ISO 14001'], + description: 'KCN VSIP Bình Dương I là khu công nghiệp lâu đời nhất của VSIP, đã lấp đầy 100%. Nằm trên trục đường chính kết nối TP.HCM và các tỉnh lân cận.', + descriptionEn: 'VSIP Binh Duong I is the oldest VSIP industrial park, fully occupied at 100%. Located on the main road connecting HCMC and neighboring provinces.', + }, + { + id: 'seed-kcn-003', + name: 'KCN Amata Đồng Nai', + nameEn: 'Amata City Bien Hoa Industrial Park', + slug: 'amata-dong-nai', + developer: 'Amata Corporation', + operator: 'Amata Vietnam', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.9457, + lng: 106.8296, + address: 'Phường Long Bình, TP Biên Hòa', + district: 'Biên Hòa', + province: 'Đồng Nai', + region: VietnamRegion.SOUTH, + totalAreaHa: 700, + leasableAreaHa: 490, + occupancyRate: 88, + remainingAreaHa: 59, + tenantCount: 180, + establishedYear: 1994, + landRentUsdM2Year: 95, + rbfRentUsdM2Month: 5.0, + rbwRentUsdM2Month: 4.5, + managementFeeUsd: 0.65, + infrastructure: { electricity: '220kV/110kV', water: '25,000 m³/day', wastewater: '12,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system + emergency center' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 30 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 25 }, highway: { name: 'QL 1A', distanceKm: 2 }, railway: { name: 'Ga Biên Hòa', distanceKm: 8 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false }, + targetIndustries: ['automotive', 'electronics', 'chemicals', 'machinery'], + existingTenants: [ + { name: 'Schaeffler', country: 'Germany', industry: 'automotive' }, + { name: 'Bosch Vietnam', country: 'Germany', industry: 'automotive' }, + { name: 'Kimberly-Clark', country: 'USA', industry: 'consumer goods' }, + ], + certifications: ['ISO 14001', 'OHSAS 18001'], + description: 'KCN Amata Đồng Nai là một trong những KCN lớn nhất miền Nam với quy hoạch kiểu thành phố công nghiệp. Gần sân bay Long Thành đang xây dựng.', + descriptionEn: 'Amata Dong Nai is one of the largest industrial parks in Southern Vietnam with an industrial city-style layout. Near the under-construction Long Thanh airport.', + }, + { + id: 'seed-kcn-004', + name: 'KCN Amata Long An', + nameEn: 'Amata City Long An Industrial Park', + slug: 'amata-long-an', + developer: 'Amata Corporation', + operator: 'Amata Vietnam', + status: IndustrialParkStatus.UNDER_CONSTRUCTION, + lat: 10.6589, + lng: 106.4752, + address: 'Xã Hựu Thạnh, Huyện Đức Hòa', + district: 'Đức Hòa', + province: 'Long An', + region: VietnamRegion.SOUTH, + totalAreaHa: 410, + leasableAreaHa: 290, + occupancyRate: 35, + remainingAreaHa: 188, + tenantCount: 25, + establishedYear: 2020, + landRentUsdM2Year: 75, + rbfRentUsdM2Month: 4.5, + rbwRentUsdM2Month: 3.8, + managementFeeUsd: 0.55, + infrastructure: { electricity: '110kV/22kV', water: '10,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 45 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 35 }, highway: { name: 'Vành đai 3 TP.HCM', distanceKm: 8 }, seaport: { name: 'Cảng Hiệp Phước', distanceKm: 30 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 5 năm đầu', specialZone: true }, + targetIndustries: ['logistics', 'food processing', 'consumer goods', 'light manufacturing'], + existingTenants: [ + { name: 'Nippon Express', country: 'Japan', industry: 'logistics' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Amata Long An là dự án mở rộng mới của Amata, hưởng lợi từ vành đai 3 TP.HCM. Giá thuê cạnh tranh và nhiều ưu đãi cho nhà đầu tư.', + descriptionEn: 'Amata Long An is a new expansion project by Amata, benefiting from HCMC Ring Road 3. Competitive rental prices and many incentives for investors.', + }, + { + id: 'seed-kcn-005', + name: 'KCN Nam Đình Vũ', + nameEn: 'Nam Dinh Vu Industrial Park', + slug: 'nam-dinh-vu', + developer: 'Sao Đỏ Group', + operator: 'Sao Đỏ Group', + status: IndustrialParkStatus.OPERATIONAL, + lat: 20.8165, + lng: 106.7833, + address: 'Đường Đình Vũ, Quận Hải An', + district: 'Hải An', + province: 'Hải Phòng', + region: VietnamRegion.NORTH, + totalAreaHa: 1329, + leasableAreaHa: 900, + occupancyRate: 75, + remainingAreaHa: 225, + tenantCount: 120, + establishedYear: 2014, + landRentUsdM2Year: 80, + rbfRentUsdM2Month: 4.8, + rbwRentUsdM2Month: 4.0, + managementFeeUsd: 0.6, + infrastructure: { electricity: '110kV/22kV', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Đình Vũ', distanceKm: 2 }, airport: { name: 'Cát Bi', distanceKm: 15 }, highway: { name: 'Cao tốc Hà Nội - Hải Phòng', distanceKm: 10 }, seaport: { name: 'Cảng nước sâu Lạch Huyện', distanceKm: 20 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 7 năm', specialZone: true }, + targetIndustries: ['petrochemicals', 'logistics', 'heavy industry', 'steel'], + existingTenants: [ + { name: 'VinFast', country: 'Vietnam', industry: 'automotive' }, + { name: 'Bridgestone', country: 'Japan', industry: 'automotive' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Nam Đình Vũ có vị trí đắc địa ngay cạnh cảng nước sâu Hải Phòng, là lựa chọn hàng đầu cho ngành logistics và công nghiệp nặng.', + descriptionEn: 'Nam Dinh Vu IP has a prime location next to Hai Phong deep-water port, a top choice for logistics and heavy industry.', + }, + { + id: 'seed-kcn-006', + name: 'KCN Long Hậu', + nameEn: 'Long Hau Industrial Park', + slug: 'long-hau', + developer: 'Long Hau Corporation', + operator: 'Long Hau Corporation', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.6108, + lng: 106.7173, + address: 'Xã Long Hậu, Huyện Cần Giuộc', + district: 'Cần Giuộc', + province: 'Long An', + region: VietnamRegion.SOUTH, + totalAreaHa: 311, + leasableAreaHa: 220, + occupancyRate: 85, + remainingAreaHa: 33, + tenantCount: 140, + establishedYear: 2006, + landRentUsdM2Year: 85, + rbfRentUsdM2Month: 4.5, + rbwRentUsdM2Month: 3.8, + managementFeeUsd: 0.5, + infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '6,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hiệp Phước', distanceKm: 5 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 25 }, highway: { name: 'Nguyễn Hữu Thọ', distanceKm: 3 }, seaport: { name: 'Cảng Hiệp Phước', distanceKm: 5 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false }, + targetIndustries: ['logistics', 'food processing', 'garment', 'packaging'], + existingTenants: [ + { name: 'DHL Supply Chain', country: 'Germany', industry: 'logistics' }, + { name: 'Yakult Vietnam', country: 'Japan', industry: 'food processing' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Long Hậu nằm gần cảng Hiệp Phước và khu đô thị Phú Mỹ Hưng, thuận lợi cho logistics và sản xuất nhẹ.', + descriptionEn: 'Long Hau IP is near Hiep Phuoc port and Phu My Hung urban area, convenient for logistics and light manufacturing.', + }, + { + id: 'seed-kcn-007', + name: 'KCN Tân Thuận (EPZ)', + nameEn: 'Tan Thuan Export Processing Zone', + slug: 'tan-thuan-epz', + developer: 'Tân Thuận Corporation', + operator: 'Tân Thuận IPC', + status: IndustrialParkStatus.FULL, + lat: 10.7357, + lng: 106.7203, + address: 'Đường Tân Thuận, Quận 7', + district: 'Quận 7', + province: 'TP. Hồ Chí Minh', + region: VietnamRegion.SOUTH, + totalAreaHa: 300, + leasableAreaHa: 210, + occupancyRate: 100, + remainingAreaHa: 0, + tenantCount: 200, + establishedYear: 1991, + landRentUsdM2Year: 130, + rbfRentUsdM2Month: 7.0, + rbwRentUsdM2Month: 6.0, + managementFeeUsd: 0.9, + infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 15 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 12 }, highway: { name: 'Nguyễn Văn Linh', distanceKm: 1 }, seaport: { name: 'Cảng SPCT', distanceKm: 8 } }, + incentives: { taxHoliday: 'EPZ ưu đãi đặc biệt: 4 năm miễn', importDuty: 'Miễn thuế NK toàn bộ (EPZ)', landRentReduction: 'N/A (đã lấp đầy)', specialZone: true }, + targetIndustries: ['electronics', 'precision engineering', 'software', 'export manufacturing'], + existingTenants: [ + { name: 'Nidec Vietnam', country: 'Japan', industry: 'electronics' }, + { name: 'Texas Instruments', country: 'USA', industry: 'semiconductors' }, + ], + certifications: ['ISO 14001', 'EPZ certification'], + description: 'KCN Tân Thuận là khu chế xuất đầu tiên của Việt Nam, nằm ngay trung tâm Quận 7 TP.HCM. Đã lấp đầy 100% với hơn 200 doanh nghiệp.', + descriptionEn: 'Tan Thuan is Vietnam\'s first export processing zone, located in District 7, HCMC. Fully occupied with over 200 enterprises.', + }, + { + id: 'seed-kcn-008', + name: 'KCN Thăng Long', + nameEn: 'Thang Long Industrial Park', + slug: 'thang-long', + developer: 'Sumitomo Corporation', + operator: 'Thang Long IP Co.', + status: IndustrialParkStatus.FULL, + lat: 21.0468, + lng: 105.7619, + address: 'Xã Võng La, Huyện Đông Anh', + district: 'Đông Anh', + province: 'Hà Nội', + region: VietnamRegion.NORTH, + totalAreaHa: 274, + leasableAreaHa: 198, + occupancyRate: 100, + remainingAreaHa: 0, + tenantCount: 110, + establishedYear: 1997, + landRentUsdM2Year: 105, + rbfRentUsdM2Month: 6.0, + rbwRentUsdM2Month: 5.0, + managementFeeUsd: 0.75, + infrastructure: { electricity: '110kV/22kV', water: '12,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 120 }, airport: { name: 'Nội Bài', distanceKm: 16 }, highway: { name: 'Nội Bài - Lào Cai', distanceKm: 5 }, railway: { name: 'Ga Đông Anh', distanceKm: 10 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'N/A (đã lấp đầy)', specialZone: false }, + targetIndustries: ['electronics', 'automotive', 'precision mechanics', 'IT'], + existingTenants: [ + { name: 'Canon Vietnam', country: 'Japan', industry: 'electronics' }, + { name: 'Panasonic', country: 'Japan', industry: 'electronics' }, + { name: 'Toyota Boshoku', country: 'Japan', industry: 'automotive' }, + ], + certifications: ['ISO 14001', 'Japan quality standards'], + description: 'KCN Thăng Long do Sumitomo phát triển, là KCN tiêu chuẩn Nhật Bản đầu tiên tại Hà Nội. Tập trung các doanh nghiệp Nhật Bản hàng đầu.', + descriptionEn: 'Thang Long IP, developed by Sumitomo, is the first Japanese-standard industrial park in Hanoi. Home to leading Japanese enterprises.', + }, + { + id: 'seed-kcn-009', + name: 'KCN KTG Industrial Nhơn Trạch', + nameEn: 'KTG Industrial Nhon Trach', + slug: 'ktg-nhon-trach', + developer: 'KTG Industrial', + operator: 'KTG Industrial', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.7412, + lng: 106.8978, + address: 'Xã Phước Thiền, Huyện Nhơn Trạch', + district: 'Nhơn Trạch', + province: 'Đồng Nai', + region: VietnamRegion.SOUTH, + totalAreaHa: 250, + leasableAreaHa: 180, + occupancyRate: 78, + remainingAreaHa: 40, + tenantCount: 65, + establishedYear: 2018, + landRentUsdM2Year: 80, + rbfRentUsdM2Month: 4.8, + rbwRentUsdM2Month: 4.0, + managementFeeUsd: 0.55, + infrastructure: { electricity: '110kV/22kV', water: '10,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 20 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 15 }, highway: { name: 'Cao tốc Long Thành - Dầu Giây', distanceKm: 5 }, seaport: { name: 'Cảng Phước An', distanceKm: 10 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm đầu', specialZone: false }, + targetIndustries: ['logistics', 'e-commerce fulfillment', 'light manufacturing', 'food processing'], + existingTenants: [ + { name: 'Lazada Logistics', country: 'Singapore', industry: 'e-commerce' }, + ], + certifications: ['ISO 14001'], + description: 'KCN KTG Nhơn Trạch chuyên về nhà xưởng xây sẵn và logistics, gần sân bay Long Thành đang xây dựng.', + descriptionEn: 'KTG Nhon Trach specializes in ready-built factories and logistics, near the under-construction Long Thanh airport.', + }, + { + id: 'seed-kcn-010', + name: 'KCN Prodezi Nhơn Trạch', + nameEn: 'Prodezi Nhon Trach Industrial Park', + slug: 'prodezi-nhon-trach', + developer: 'Prodezi Vietnam', + operator: 'Prodezi Vietnam', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.7518, + lng: 106.8845, + address: 'Xã Hiệp Phước, Huyện Nhơn Trạch', + district: 'Nhơn Trạch', + province: 'Đồng Nai', + region: VietnamRegion.SOUTH, + totalAreaHa: 340, + leasableAreaHa: 245, + occupancyRate: 70, + remainingAreaHa: 73, + tenantCount: 55, + establishedYear: 2015, + landRentUsdM2Year: 72, + rbfRentUsdM2Month: 4.2, + rbwRentUsdM2Month: 3.5, + managementFeeUsd: 0.5, + infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '6,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 25 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 12 }, highway: { name: 'QL 51', distanceKm: 8 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 3 năm đầu', specialZone: false }, + targetIndustries: ['machinery', 'plastics', 'packaging', 'consumer goods'], + existingTenants: [ + { name: 'Tetra Pak', country: 'Sweden', industry: 'packaging' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Prodezi Nhơn Trạch với giá thuê cạnh tranh và vị trí gần sân bay Long Thành, phù hợp cho sản xuất và logistics.', + descriptionEn: 'Prodezi Nhon Trach offers competitive rental prices near Long Thanh airport, suitable for manufacturing and logistics.', + }, + { + id: 'seed-kcn-011', + name: 'KCN Thăng Long II Hưng Yên', + nameEn: 'Thang Long II Hung Yen Industrial Park', + slug: 'thang-long-2-hung-yen', + developer: 'Sumitomo Corporation', + operator: 'Thang Long IP Co.', + status: IndustrialParkStatus.OPERATIONAL, + lat: 20.8742, + lng: 106.0165, + address: 'Xã Dị Sử, Huyện Mỹ Hào', + district: 'Mỹ Hào', + province: 'Hưng Yên', + region: VietnamRegion.NORTH, + totalAreaHa: 345, + leasableAreaHa: 250, + occupancyRate: 82, + remainingAreaHa: 45, + tenantCount: 85, + establishedYear: 2004, + landRentUsdM2Year: 78, + rbfRentUsdM2Month: 4.5, + rbwRentUsdM2Month: 3.8, + managementFeeUsd: 0.6, + infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 85 }, airport: { name: 'Nội Bài', distanceKm: 50 }, highway: { name: 'QL 5', distanceKm: 3 }, railway: { name: 'Ga Lạc Đạo', distanceKm: 5 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm đầu', specialZone: false }, + targetIndustries: ['electronics', 'automotive parts', 'precision engineering'], + existingTenants: [ + { name: 'Sumitomo Electric', country: 'Japan', industry: 'electronics' }, + { name: 'TOTO Vietnam', country: 'Japan', industry: 'ceramics' }, + ], + certifications: ['ISO 14001', 'Japan quality standards'], + description: 'KCN Thăng Long II là phần mở rộng của KCN Thăng Long tại Hưng Yên, tiếp tục thu hút các nhà đầu tư Nhật Bản.', + descriptionEn: 'Thang Long II is the expansion of Thang Long IP in Hung Yen, continuing to attract Japanese investors.', + }, + { + id: 'seed-kcn-012', + name: 'KCN Yên Phong Bắc Ninh', + nameEn: 'Yen Phong Bac Ninh Industrial Park', + slug: 'yen-phong-bac-ninh', + developer: 'Viglacera Corporation', + operator: 'Viglacera', + status: IndustrialParkStatus.OPERATIONAL, + lat: 21.1652, + lng: 106.1184, + address: 'Xã Yên Trung, Huyện Yên Phong', + district: 'Yên Phong', + province: 'Bắc Ninh', + region: VietnamRegion.NORTH, + totalAreaHa: 658, + leasableAreaHa: 460, + occupancyRate: 95, + remainingAreaHa: 23, + tenantCount: 190, + establishedYear: 2008, + landRentUsdM2Year: 85, + rbfRentUsdM2Month: 5.0, + rbwRentUsdM2Month: 4.2, + managementFeeUsd: 0.6, + infrastructure: { electricity: '110kV/22kV', water: '18,000 m³/day', wastewater: '12,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 100 }, airport: { name: 'Nội Bài', distanceKm: 30 }, highway: { name: 'QL 18', distanceKm: 5 }, railway: { name: 'Ga Bắc Ninh', distanceKm: 12 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false }, + targetIndustries: ['electronics', 'display manufacturing', 'semiconductors', 'automotive'], + existingTenants: [ + { name: 'Samsung Display', country: 'Korea', industry: 'display' }, + { name: 'Samsung SDI', country: 'Korea', industry: 'batteries' }, + { name: 'Hanwha', country: 'Korea', industry: 'defense/electronics' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Yên Phong là hub sản xuất Samsung tại Việt Nam, gần lấp đầy với hàng loạt nhà cung cấp Hàn Quốc.', + descriptionEn: 'Yen Phong is Samsung\'s manufacturing hub in Vietnam, nearly full with numerous Korean suppliers.', + }, + { + id: 'seed-kcn-013', + name: 'KCN Bà Rịa - Vũng Tàu (BRVT)', + nameEn: 'Ba Ria Vung Tau Industrial Park', + slug: 'ba-ria-vung-tau', + developer: 'Sonadezi', + operator: 'Sonadezi', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.4957, + lng: 107.1672, + address: 'Phường Long Hương, TP Bà Rịa', + district: 'TP Bà Rịa', + province: 'Bà Rịa - Vũng Tàu', + region: VietnamRegion.SOUTH, + totalAreaHa: 450, + leasableAreaHa: 320, + occupancyRate: 72, + remainingAreaHa: 90, + tenantCount: 80, + establishedYear: 2002, + landRentUsdM2Year: 65, + rbfRentUsdM2Month: 3.8, + rbwRentUsdM2Month: 3.2, + managementFeeUsd: 0.45, + infrastructure: { electricity: '220kV/110kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cái Mép - Thị Vải', distanceKm: 20 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 50 }, highway: { name: 'Cao tốc Biên Hòa - Vũng Tàu', distanceKm: 5 }, seaport: { name: 'Cảng Cái Mép', distanceKm: 20 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm đầu', specialZone: true }, + targetIndustries: ['oil & gas', 'petrochemicals', 'heavy industry', 'steel', 'logistics'], + existingTenants: [ + { name: 'Posco Vietnam', country: 'Korea', industry: 'steel' }, + { name: 'Hyosung', country: 'Korea', industry: 'chemicals' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Bà Rịa - Vũng Tàu gần cảng nước sâu Cái Mép - Thị Vải, phù hợp cho công nghiệp nặng và logistics biển.', + descriptionEn: 'BRVT IP is near Cai Mep deep-water port, suitable for heavy industry and maritime logistics.', + }, + { + id: 'seed-kcn-014', + name: 'KCN Becamex Bình Phước', + nameEn: 'Becamex Binh Phuoc Industrial Park', + slug: 'becamex-binh-phuoc', + developer: 'Becamex IDC', + operator: 'Becamex IDC', + status: IndustrialParkStatus.UNDER_CONSTRUCTION, + lat: 11.4521, + lng: 106.6438, + address: 'Xã Minh Thành, Huyện Chơn Thành', + district: 'Chơn Thành', + province: 'Bình Phước', + region: VietnamRegion.SOUTH, + totalAreaHa: 4686, + leasableAreaHa: 3200, + occupancyRate: 25, + remainingAreaHa: 2400, + tenantCount: 30, + establishedYear: 2021, + landRentUsdM2Year: 50, + rbfRentUsdM2Month: 3.5, + rbwRentUsdM2Month: 3.0, + managementFeeUsd: 0.4, + infrastructure: { electricity: '110kV/22kV (đang nâng cấp 220kV)', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes (đang xây)', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 85 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 80 }, highway: { name: 'QL 13 + cao tốc TP.HCM - Chơn Thành', distanceKm: 3 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 7 năm đầu', specialZone: true }, + targetIndustries: ['agriculture processing', 'rubber', 'wood processing', 'light manufacturing'], + existingTenants: [], + certifications: [], + description: 'KCN Becamex Bình Phước là KCN lớn nhất Việt Nam (4.686 ha), giá thuê thấp nhất khu vực, thích hợp cho ngành chế biến nông sản.', + descriptionEn: 'Becamex Binh Phuoc is Vietnam\'s largest industrial park (4,686 ha), with the lowest rental prices, suitable for agro-processing.', + }, + { + id: 'seed-kcn-015', + name: 'KCN Đại An Hải Dương', + nameEn: 'Dai An Hai Duong Industrial Park', + slug: 'dai-an-hai-duong', + developer: 'Đại An JSC', + operator: 'Đại An JSC', + status: IndustrialParkStatus.OPERATIONAL, + lat: 20.9178, + lng: 106.3215, + address: 'Xã Đại An, TP Hải Dương', + district: 'TP Hải Dương', + province: 'Hải Dương', + region: VietnamRegion.NORTH, + totalAreaHa: 174, + leasableAreaHa: 130, + occupancyRate: 90, + remainingAreaHa: 13, + tenantCount: 70, + establishedYear: 2003, + landRentUsdM2Year: 70, + rbfRentUsdM2Month: 4.2, + rbwRentUsdM2Month: 3.5, + managementFeeUsd: 0.5, + infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '5,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 50 }, airport: { name: 'Nội Bài', distanceKm: 60 }, highway: { name: 'QL 5', distanceKm: 2 }, railway: { name: 'Ga Hải Dương', distanceKm: 5 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false }, + targetIndustries: ['garment', 'food processing', 'mechanics', 'electronics assembly'], + existingTenants: [ + { name: 'Ford Vietnam (parts)', country: 'USA', industry: 'automotive' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Đại An nằm trên trục QL 5 Hà Nội - Hải Phòng, gần lấp đầy, phù hợp cho sản xuất và gia công.', + descriptionEn: 'Dai An IP is on the Hanoi-Hai Phong highway corridor, nearly full, suitable for manufacturing and processing.', + }, + { + id: 'seed-kcn-016', + name: 'KCN DEEP C Hải Phòng', + nameEn: 'DEEP C Hai Phong Industrial Zones', + slug: 'deep-c-hai-phong', + developer: 'DEEP C (Belgium)', + operator: 'DEEP C Industrial Zones', + status: IndustrialParkStatus.OPERATIONAL, + lat: 20.8312, + lng: 106.7198, + address: 'Phường Đông Hải, Quận Hải An', + district: 'Hải An', + province: 'Hải Phòng', + region: VietnamRegion.NORTH, + totalAreaHa: 3000, + leasableAreaHa: 2100, + occupancyRate: 68, + remainingAreaHa: 672, + tenantCount: 150, + establishedYear: 1997, + landRentUsdM2Year: 75, + rbfRentUsdM2Month: 4.5, + rbwRentUsdM2Month: 3.8, + managementFeeUsd: 0.6, + infrastructure: { electricity: '220kV/110kV', water: '30,000 m³/day', wastewater: '20,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system + emergency center' }, + connectivity: { nearestPort: { name: 'Cảng Đình Vũ', distanceKm: 5 }, airport: { name: 'Cát Bi', distanceKm: 12 }, highway: { name: 'Cao tốc Hà Nội - Hải Phòng', distanceKm: 8 }, seaport: { name: 'Cảng Lạch Huyện', distanceKm: 15 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm', specialZone: true }, + targetIndustries: ['petrochemicals', 'LNG', 'electronics', 'logistics', 'renewable energy'], + existingTenants: [ + { name: 'LG Display', country: 'Korea', industry: 'display' }, + { name: 'Pegatron', country: 'Taiwan', industry: 'electronics' }, + ], + certifications: ['ISO 14001', 'EDGE Green Building', 'Belgian quality standards'], + description: 'DEEP C là cụm KCN lớn nhất Hải Phòng do Bỉ phát triển, với cam kết phát triển bền vững và năng lượng tái tạo.', + descriptionEn: 'DEEP C is Hai Phong\'s largest industrial zone cluster, developed by Belgium, with commitment to sustainability and renewable energy.', + }, + { + id: 'seed-kcn-017', + name: 'KCN Mỹ Phước 3 Bình Dương', + nameEn: 'My Phuoc 3 Binh Duong Industrial Park', + slug: 'my-phuoc-3-binh-duong', + developer: 'Becamex IDC', + operator: 'Becamex IDC', + status: IndustrialParkStatus.OPERATIONAL, + lat: 11.1245, + lng: 106.5867, + address: 'Phường Mỹ Phước, TP Bến Cát', + district: 'Bến Cát', + province: 'Bình Dương', + region: VietnamRegion.SOUTH, + totalAreaHa: 992, + leasableAreaHa: 700, + occupancyRate: 87, + remainingAreaHa: 91, + tenantCount: 210, + establishedYear: 2006, + landRentUsdM2Year: 82, + rbfRentUsdM2Month: 4.8, + rbwRentUsdM2Month: 4.0, + managementFeeUsd: 0.55, + infrastructure: { electricity: '110kV/22kV', water: '25,000 m³/day', wastewater: '18,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 40 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 35 }, highway: { name: 'Mỹ Phước - Tân Vạn', distanceKm: 1 } }, + incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm', specialZone: false }, + targetIndustries: ['furniture', 'garment', 'food processing', 'electronics assembly', 'plastics'], + existingTenants: [ + { name: 'Colgate-Palmolive', country: 'USA', industry: 'consumer goods' }, + { name: 'Kumho Tire', country: 'Korea', industry: 'automotive' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Mỹ Phước 3 thuộc chuỗi KCN Becamex tại Bến Cát, là trung tâm sản xuất đa ngành lớn nhất Bình Dương.', + descriptionEn: 'My Phuoc 3 is part of Becamex\'s industrial park chain in Ben Cat, the largest multi-industry manufacturing hub in Binh Duong.', + }, + { + id: 'seed-kcn-018', + name: 'KCN Phú Mỹ 2 BRVT', + nameEn: 'Phu My 2 Industrial Park', + slug: 'phu-my-2-brvt', + developer: 'Idico-Conac', + operator: 'Idico-Conac', + status: IndustrialParkStatus.OPERATIONAL, + lat: 10.5378, + lng: 107.0412, + address: 'Xã Mỹ Xuân, TX Phú Mỹ', + district: 'TX Phú Mỹ', + province: 'Bà Rịa - Vũng Tàu', + region: VietnamRegion.SOUTH, + totalAreaHa: 380, + leasableAreaHa: 270, + occupancyRate: 65, + remainingAreaHa: 94, + tenantCount: 45, + establishedYear: 2007, + landRentUsdM2Year: 55, + rbfRentUsdM2Month: 3.5, + rbwRentUsdM2Month: 3.0, + managementFeeUsd: 0.4, + infrastructure: { electricity: '220kV/110kV', water: '12,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Cái Mép - Thị Vải', distanceKm: 10 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 40 }, highway: { name: 'QL 51', distanceKm: 3 }, seaport: { name: 'Cảng Cái Mép', distanceKm: 10 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm đầu', specialZone: true }, + targetIndustries: ['petrochemicals', 'steel', 'power generation', 'port logistics'], + existingTenants: [ + { name: 'SCG Vietnam', country: 'Thailand', industry: 'chemicals' }, + ], + certifications: ['ISO 14001'], + description: 'KCN Phú Mỹ 2 gần cảng nước sâu Cái Mép, giá thuê thấp, phù hợp cho công nghiệp nặng và hóa chất.', + descriptionEn: 'Phu My 2 IP is near Cai Mep deep-water port, low rental prices, suitable for heavy industry and chemicals.', + }, + { + id: 'seed-kcn-019', + name: 'KCN WHA Nghệ An', + nameEn: 'WHA Industrial Zone Nghe An', + slug: 'wha-nghe-an', + developer: 'WHA Group (Thailand)', + operator: 'WHA Industrial Development', + status: IndustrialParkStatus.UNDER_CONSTRUCTION, + lat: 18.7485, + lng: 105.7345, + address: 'Xã Nghi Long, Huyện Nghi Lộc', + district: 'Nghi Lộc', + province: 'Nghệ An', + region: VietnamRegion.CENTRAL, + totalAreaHa: 498, + leasableAreaHa: 350, + occupancyRate: 15, + remainingAreaHa: 297, + tenantCount: 8, + establishedYear: 2022, + landRentUsdM2Year: 45, + rbfRentUsdM2Month: 3.0, + rbwRentUsdM2Month: 2.5, + managementFeeUsd: 0.35, + infrastructure: { electricity: '110kV/22kV (đang xây)', water: '8,000 m³/day (phase 1)', wastewater: '5,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system (đang xây)' }, + connectivity: { nearestPort: { name: 'Cảng Cửa Lò', distanceKm: 15 }, airport: { name: 'Vinh', distanceKm: 20 }, highway: { name: 'QL 1A', distanceKm: 5 } }, + incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 10 năm đầu', specialZone: true }, + targetIndustries: ['electronics assembly', 'garment', 'food processing', 'rubber'], + existingTenants: [], + certifications: [], + description: 'KCN WHA Nghệ An do Thái Lan phát triển, giá thuê thấp nhất miền Trung với nhiều ưu đãi đặc biệt cho nhà đầu tư.', + descriptionEn: 'WHA Nghe An, developed by Thailand\'s WHA Group, offers the lowest rental prices in Central Vietnam with special investor incentives.', + }, + { + id: 'seed-kcn-020', + name: 'KCN Chu Lai Quảng Nam', + nameEn: 'Chu Lai Open Economic Zone', + slug: 'chu-lai-quang-nam', + developer: 'Trường Hải Auto (THACO)', + operator: 'THACO Chu Lai', + status: IndustrialParkStatus.OPERATIONAL, + lat: 15.4132, + lng: 108.6421, + address: 'Xã Tam Hiệp, Huyện Núi Thành', + district: 'Núi Thành', + province: 'Quảng Nam', + region: VietnamRegion.CENTRAL, + totalAreaHa: 1550, + leasableAreaHa: 1100, + occupancyRate: 55, + remainingAreaHa: 495, + tenantCount: 60, + establishedYear: 2003, + landRentUsdM2Year: 40, + rbfRentUsdM2Month: 2.8, + rbwRentUsdM2Month: 2.2, + managementFeeUsd: 0.35, + infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' }, + connectivity: { nearestPort: { name: 'Cảng Kỳ Hà', distanceKm: 5 }, airport: { name: 'Chu Lai', distanceKm: 8 }, highway: { name: 'QL 1A', distanceKm: 3 }, seaport: { name: 'Cảng Kỳ Hà', distanceKm: 5 } }, + incentives: { taxHoliday: 'KKTM đặc biệt: 4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK toàn bộ (KKTM)', landRentReduction: 'Miễn tiền thuê đất 15 năm', specialZone: true }, + targetIndustries: ['automotive', 'agriculture machinery', 'wood processing', 'seafood processing'], + existingTenants: [ + { name: 'THACO (Kia, Mazda, Peugeot)', country: 'Vietnam', industry: 'automotive' }, + { name: 'THACO Industries', country: 'Vietnam', industry: 'machinery' }, + ], + certifications: ['ISO 14001', 'Special Economic Zone'], + description: 'KCN Chu Lai thuộc Khu kinh tế mở Chu Lai, do THACO phát triển chủ đạo. Là hub ô tô lớn nhất Việt Nam.', + descriptionEn: 'Chu Lai IP is in Chu Lai Open Economic Zone, primarily developed by THACO. Vietnam\'s largest automotive hub.', + }, +]; + +export async function seedIndustrialParks() { + console.log('🏭 Seeding industrial parks...'); + + for (const p of PARKS) { + await prisma.$executeRawUnsafe( + `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 ( + $1, $2, $3, $4, $5, $6, $7::"IndustrialParkStatus", + ST_SetSRID(ST_MakePoint($8, $9), 4326), + $10, $11, $12, $13::"VietnamRegion", $14, $15, $16, $17, $18, $19, + $20, $21, $22, $23, $24::jsonb, $25::jsonb, $26::jsonb, + $27::text[], $28::jsonb, $29::jsonb, NULL, NULL, + $30, $31, true, NOW(), NOW() + ) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + "nameEn" = EXCLUDED."nameEn", + developer = EXCLUDED.developer, + operator = EXCLUDED.operator, + status = EXCLUDED.status, + "occupancyRate" = EXCLUDED."occupancyRate", + "remainingAreaHa" = EXCLUDED."remainingAreaHa", + "tenantCount" = EXCLUDED."tenantCount", + "landRentUsdM2Year" = EXCLUDED."landRentUsdM2Year", + "rbfRentUsdM2Month" = EXCLUDED."rbfRentUsdM2Month", + "rbwRentUsdM2Month" = EXCLUDED."rbwRentUsdM2Month", + "updatedAt" = NOW()`, + p.id, + p.name, + p.nameEn, + p.slug, + p.developer, + p.operator, + p.status, + p.lng, // ST_MakePoint(lng, lat) + p.lat, + p.address, + p.district, + p.province, + p.region, + p.totalAreaHa, + p.leasableAreaHa, + p.occupancyRate, + p.remainingAreaHa, + p.tenantCount, + p.establishedYear, + p.landRentUsdM2Year, + p.rbfRentUsdM2Month, + p.rbwRentUsdM2Month, + p.managementFeeUsd, + JSON.stringify(p.infrastructure), + JSON.stringify(p.connectivity), + JSON.stringify(p.incentives), + p.targetIndustries, + JSON.stringify(p.existingTenants), + JSON.stringify(p.certifications), + p.description, + p.descriptionEn, + ); + console.log(` ✓ ${p.name}`); + } + + console.log(`🏭 Seeded ${PARKS.length} industrial parks.`); +} + +// Run standalone +async function main() { + try { + await seedIndustrialParks(); + } catch (err) { + console.error('Seed error:', err); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +main(); diff --git a/scripts/seed-reports-data.ts b/scripts/seed-reports-data.ts new file mode 100644 index 0000000..8c211d2 --- /dev/null +++ b/scripts/seed-reports-data.ts @@ -0,0 +1,116 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Seeding macroeconomic data...'); + + // ─── Macroeconomic Data ──────────────────────────────── + const macroData = [ + // Ho Chi Minh City + { province: 'Hồ Chí Minh', indicator: 'gdp', value: 1680000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'gdp', value: 1550000, unit: 'tỷ VND', period: '2024', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'gdp', value: 1420000, unit: 'tỷ VND', period: '2023', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'fdi', value: 8200, unit: 'triệu USD', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'fdi', value: 7500, unit: 'triệu USD', period: '2024', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'population', value: 10200000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'urbanization', value: 87.5, unit: '%', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'labor_force', value: 5100000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'avg_wage', value: 420, unit: 'USD/tháng', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'cpi', value: 4.2, unit: '%', period: '2025', source: 'GSO' }, + { province: 'Hồ Chí Minh', indicator: 'mortgage_rate', value: 8.5, unit: '%', period: '2025', source: 'SBV' }, + + // Binh Duong + { province: 'Bình Dương', indicator: 'gdp', value: 480000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'gdp', value: 440000, unit: 'tỷ VND', period: '2024', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'gdp', value: 405000, unit: 'tỷ VND', period: '2023', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'fdi', value: 3800, unit: 'triệu USD', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'fdi', value: 3500, unit: 'triệu USD', period: '2024', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'population', value: 2800000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'urbanization', value: 82.0, unit: '%', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'labor_force', value: 1600000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'avg_wage', value: 350, unit: 'USD/tháng', period: '2025', source: 'GSO' }, + { province: 'Bình Dương', indicator: 'industrial_output', value: 380000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + + // Dong Nai + { province: 'Đồng Nai', indicator: 'gdp', value: 420000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'gdp', value: 385000, unit: 'tỷ VND', period: '2024', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'fdi', value: 4200, unit: 'triệu USD', period: '2025', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'population', value: 3250000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'labor_force', value: 1800000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'avg_wage', value: 340, unit: 'USD/tháng', period: '2025', source: 'GSO' }, + { province: 'Đồng Nai', indicator: 'industrial_output', value: 350000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + + // Ha Noi + { province: 'Hà Nội', indicator: 'gdp', value: 1250000, unit: 'tỷ VND', period: '2025', source: 'GSO' }, + { province: 'Hà Nội', indicator: 'fdi', value: 6500, unit: 'triệu USD', period: '2025', source: 'GSO' }, + { province: 'Hà Nội', indicator: 'population', value: 8600000, unit: 'người', period: '2025', source: 'GSO' }, + { province: 'Hà Nội', indicator: 'urbanization', value: 72.0, unit: '%', period: '2025', source: 'GSO' }, + { province: 'Hà Nội', indicator: 'mortgage_rate', value: 8.5, unit: '%', period: '2025', source: 'SBV' }, + ]; + + for (const d of macroData) { + await prisma.macroeconomicData.upsert({ + where: { province_indicator_period: { province: d.province, indicator: d.indicator, period: d.period } }, + create: d, + update: { value: d.value, unit: d.unit, source: d.source }, + }); + } + console.log(` Seeded ${macroData.length} macroeconomic data points.`); + + // ─── Infrastructure Projects ─────────────────────────── + console.log('Seeding infrastructure projects...'); + + const infraProjects = [ + { name: 'Metro Bến Thành - Suối Tiên (Line 1)', province: 'Hồ Chí Minh', category: 'metro', status: 'under_construction', investmentVND: BigInt(43_700_000_000_000), description: '19.7km, 14 ga, dự kiến vận hành 2024-2025' }, + { name: 'Metro Bến Thành - Tham Lương (Line 2)', province: 'Hồ Chí Minh', category: 'metro', status: 'planning', investmentVND: BigInt(47_800_000_000_000), description: '11.3km, 10 ga' }, + { name: 'Cao tốc TP.HCM - Long Thành - Dầu Giây (mở rộng)', province: 'Hồ Chí Minh', category: 'highway', status: 'under_construction', investmentVND: BigInt(9_000_000_000_000), description: 'Mở rộng lên 8-10 làn' }, + { name: 'Sân bay Long Thành - Giai đoạn 1', province: 'Đồng Nai', category: 'airport', status: 'under_construction', investmentVND: BigInt(109_000_000_000_000), description: 'Công suất 25 triệu HK/năm' }, + { name: 'Cảng Cái Mép - Thị Vải (mở rộng)', province: 'Bà Rịa - Vũng Tàu', category: 'port', status: 'completed', investmentVND: BigInt(12_000_000_000_000), description: 'Cảng container nước sâu lớn nhất VN' }, + { name: 'Vành đai 3 TP.HCM', province: 'Hồ Chí Minh', category: 'highway', status: 'under_construction', investmentVND: BigInt(75_400_000_000_000), description: '76km qua 4 tỉnh thành' }, + { name: 'Cao tốc Bến Lức - Long Thành', province: 'Đồng Nai', category: 'highway', status: 'under_construction', investmentVND: BigInt(31_300_000_000_000), description: '57.8km, kết nối BD-DN' }, + { name: 'Metro Nhổn - Ga Hà Nội (Line 3)', province: 'Hà Nội', category: 'metro', status: 'under_construction', investmentVND: BigInt(34_800_000_000_000), description: '12.5km' }, + { name: 'Đường sắt tốc độ cao Bắc Nam (GĐ1)', province: 'Hà Nội', category: 'railway', status: 'planning', investmentVND: BigInt(670_000_000_000_000), description: 'HN-Vinh & NhaTrang-TPHCM' }, + { name: 'Cao tốc Mỹ Phước - Tân Vạn (mở rộng)', province: 'Bình Dương', category: 'highway', status: 'under_construction', investmentVND: BigInt(4_500_000_000_000), description: 'Kết nối KCN Bình Dương' }, + ]; + + for (const p of infraProjects) { + const existing = await prisma.infrastructureProject.findFirst({ + where: { name: p.name, province: p.province }, + }); + if (!existing) { + await prisma.infrastructureProject.create({ data: p }); + } + } + console.log(` Seeded ${infraProjects.length} infrastructure projects.`); + + // ─── Update Plan maxReports ──────────────────────────── + console.log('Updating plan quotas for reports...'); + + await prisma.plan.updateMany({ + where: { tier: 'FREE' }, + data: { maxReports: 0 }, + }); + await prisma.plan.updateMany({ + where: { tier: 'AGENT_PRO' }, + data: { maxReports: 2 }, + }); + await prisma.plan.updateMany({ + where: { tier: 'INVESTOR' }, + data: { maxReports: 5 }, + }); + await prisma.plan.updateMany({ + where: { tier: 'ENTERPRISE' }, + data: { maxReports: 999 }, + }); + console.log(' Plan quotas updated.'); + + console.log('Done!'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect());