feat(api): add industrial, transfer, and reports backend modules
Add three new NestJS modules following DDD/CQRS architecture: - Industrial: KCN (industrial park) management with PostGIS geo queries, Typesense search, and market statistics - Transfer: Furniture/premises transfer listings with AI-powered price estimation and depreciation modeling - Reports: Async AI report generation via BullMQ with Claude narrative service, PDF generation, and macro data integration Includes Prisma schema models, migrations, seed scripts, and app.module wiring with BullMQ Redis config. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export class DeleteReportCommand {
|
||||
constructor(
|
||||
public readonly reportId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { DeleteReportCommand } from './delete-report.command';
|
||||
|
||||
@CommandHandler(DeleteReportCommand)
|
||||
export class DeleteReportHandler implements ICommandHandler<DeleteReportCommand, void> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteReportCommand): Promise<void> {
|
||||
const report = await this.reportRepo.findById(command.reportId);
|
||||
|
||||
if (!report) {
|
||||
throw new NotFoundException('Báo cáo không tồn tại.');
|
||||
}
|
||||
|
||||
if (report.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa báo cáo này.');
|
||||
}
|
||||
|
||||
await this.reportRepo.delete(command.reportId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ReportType } from '../../../domain/enums/report-type.enum';
|
||||
|
||||
export class GenerateReportCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly type: ReportType,
|
||||
public readonly title: string,
|
||||
public readonly params: Record<string, unknown>,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type Queue } from 'bullmq';
|
||||
import { ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { GenerateReportCommand } from './generate-report.command';
|
||||
|
||||
export const REPORT_GENERATION_QUEUE = 'report-generation';
|
||||
|
||||
export interface GenerateReportResult {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
@CommandHandler(GenerateReportCommand)
|
||||
export class GenerateReportHandler implements ICommandHandler<GenerateReportCommand, GenerateReportResult> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
@InjectQueue(REPORT_GENERATION_QUEUE) private readonly reportQueue: Queue,
|
||||
) {}
|
||||
|
||||
async execute(command: GenerateReportCommand): Promise<GenerateReportResult> {
|
||||
const report = ReportEntity.createNew(
|
||||
createId(),
|
||||
command.userId,
|
||||
command.type,
|
||||
command.title,
|
||||
command.params,
|
||||
);
|
||||
|
||||
await this.reportRepo.save(report);
|
||||
|
||||
await this.reportQueue.add('generate', {
|
||||
reportId: report.id,
|
||||
}, {
|
||||
attempts: 2,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
});
|
||||
|
||||
return { reportId: report.id };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/modules/reports/application/index.ts
Normal file
8
apps/api/src/modules/reports/application/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { GenerateReportCommand } from './commands/generate-report/generate-report.command';
|
||||
export { GenerateReportHandler, REPORT_GENERATION_QUEUE } from './commands/generate-report/generate-report.handler';
|
||||
export { DeleteReportCommand } from './commands/delete-report/delete-report.command';
|
||||
export { DeleteReportHandler } from './commands/delete-report/delete-report.handler';
|
||||
export { GetReportQuery } from './queries/get-report/get-report.query';
|
||||
export { GetReportHandler } from './queries/get-report/get-report.handler';
|
||||
export { ListReportsQuery } from './queries/list-reports/list-reports.query';
|
||||
export { ListReportsHandler } from './queries/list-reports/list-reports.handler';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { GetReportQuery } from './get-report.query';
|
||||
|
||||
@QueryHandler(GetReportQuery)
|
||||
export class GetReportHandler implements IQueryHandler<GetReportQuery, ReportEntity> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReportQuery): Promise<ReportEntity> {
|
||||
const report = await this.reportRepo.findById(query.reportId);
|
||||
|
||||
if (!report) {
|
||||
throw new NotFoundException('Báo cáo không tồn tại.');
|
||||
}
|
||||
|
||||
if (report.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem báo cáo này.');
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetReportQuery {
|
||||
constructor(
|
||||
public readonly reportId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReportEntity } from '../../../domain/entities/report.entity';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../../domain/repositories/report.repository';
|
||||
import { ListReportsQuery } from './list-reports.query';
|
||||
|
||||
export interface ListReportsResult {
|
||||
reports: ReportEntity[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@QueryHandler(ListReportsQuery)
|
||||
export class ListReportsHandler implements IQueryHandler<ListReportsQuery, ListReportsResult> {
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListReportsQuery): Promise<ListReportsResult> {
|
||||
return this.reportRepo.findByUserId({
|
||||
userId: query.userId,
|
||||
type: query.type,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ReportType } from '../../../domain/enums/report-type.enum';
|
||||
|
||||
export class ListReportsQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly type?: ReportType,
|
||||
public readonly limit: number = 20,
|
||||
public readonly offset: number = 0,
|
||||
) {}
|
||||
}
|
||||
1
apps/api/src/modules/reports/domain/entities/index.ts
Normal file
1
apps/api/src/modules/reports/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ReportEntity, type ReportProps } from './report.entity';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
import { ReportStatus } from '../enums/report-status.enum';
|
||||
import { type ReportType } from '../enums/report-type.enum';
|
||||
|
||||
export interface ReportProps {
|
||||
userId: string;
|
||||
type: ReportType;
|
||||
title: string;
|
||||
params: Record<string, unknown>;
|
||||
content: Record<string, unknown> | null;
|
||||
pdfUrl: string | null;
|
||||
status: ReportStatus;
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
export class ReportEntity extends AggregateRoot<string> {
|
||||
private _userId: string;
|
||||
private _type: ReportType;
|
||||
private _title: string;
|
||||
private _params: Record<string, unknown>;
|
||||
private _content: Record<string, unknown> | null;
|
||||
private _pdfUrl: string | null;
|
||||
private _status: ReportStatus;
|
||||
private _errorMsg: string | null;
|
||||
|
||||
constructor(id: string, props: ReportProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._userId = props.userId;
|
||||
this._type = props.type;
|
||||
this._title = props.title;
|
||||
this._params = props.params;
|
||||
this._content = props.content;
|
||||
this._pdfUrl = props.pdfUrl;
|
||||
this._status = props.status;
|
||||
this._errorMsg = props.errorMsg;
|
||||
}
|
||||
|
||||
get userId(): string { return this._userId; }
|
||||
get type(): ReportType { return this._type; }
|
||||
get title(): string { return this._title; }
|
||||
get params(): Record<string, unknown> { return this._params; }
|
||||
get content(): Record<string, unknown> | null { return this._content; }
|
||||
get pdfUrl(): string | null { return this._pdfUrl; }
|
||||
get status(): ReportStatus { return this._status; }
|
||||
get errorMsg(): string | null { return this._errorMsg; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
userId: string,
|
||||
type: ReportType,
|
||||
title: string,
|
||||
params: Record<string, unknown>,
|
||||
): ReportEntity {
|
||||
return new ReportEntity(id, {
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
params,
|
||||
content: null,
|
||||
pdfUrl: null,
|
||||
status: ReportStatus.GENERATING,
|
||||
errorMsg: null,
|
||||
});
|
||||
}
|
||||
|
||||
markReady(content: Record<string, unknown>, pdfUrl: string | null): void {
|
||||
this._content = content;
|
||||
this._pdfUrl = pdfUrl;
|
||||
this._status = ReportStatus.READY;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
markFailed(errorMsg: string): void {
|
||||
this._status = ReportStatus.FAILED;
|
||||
this._errorMsg = errorMsg;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
2
apps/api/src/modules/reports/domain/enums/index.ts
Normal file
2
apps/api/src/modules/reports/domain/enums/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ReportType } from './report-type.enum';
|
||||
export { ReportStatus } from './report-status.enum';
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ReportStatus {
|
||||
GENERATING = 'GENERATING',
|
||||
READY = 'READY',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum ReportType {
|
||||
RESIDENTIAL_MARKET = 'RESIDENTIAL_MARKET',
|
||||
INDUSTRIAL_MARKET = 'INDUSTRIAL_MARKET',
|
||||
DISTRICT_ANALYSIS = 'DISTRICT_ANALYSIS',
|
||||
INVESTMENT_FEASIBILITY = 'INVESTMENT_FEASIBILITY',
|
||||
INDUSTRIAL_LOCATION = 'INDUSTRIAL_LOCATION',
|
||||
PROPERTY_VALUATION = 'PROPERTY_VALUATION',
|
||||
PORTFOLIO = 'PORTFOLIO',
|
||||
}
|
||||
4
apps/api/src/modules/reports/domain/index.ts
Normal file
4
apps/api/src/modules/reports/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './enums';
|
||||
export * from './repositories';
|
||||
export * from './services';
|
||||
@@ -0,0 +1 @@
|
||||
export { REPORT_REPOSITORY, type IReportRepository, type ListReportsFilter } from './report.repository';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ReportEntity } from '../entities/report.entity';
|
||||
import { type ReportStatus } from '../enums/report-status.enum';
|
||||
import { type ReportType } from '../enums/report-type.enum';
|
||||
|
||||
export const REPORT_REPOSITORY = Symbol('REPORT_REPOSITORY');
|
||||
|
||||
export interface ListReportsFilter {
|
||||
userId: string;
|
||||
type?: ReportType;
|
||||
status?: ReportStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface IReportRepository {
|
||||
findById(id: string): Promise<ReportEntity | null>;
|
||||
findByUserId(filter: ListReportsFilter): Promise<{ reports: ReportEntity[]; total: number }>;
|
||||
save(entity: ReportEntity): Promise<void>;
|
||||
update(entity: ReportEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const AI_NARRATIVE_SERVICE = Symbol('AI_NARRATIVE_SERVICE');
|
||||
|
||||
export interface NarrativeRequest {
|
||||
reportType: string;
|
||||
sectionKey: string;
|
||||
sectionTitle: string;
|
||||
context: Record<string, unknown>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface IAINarrativeService {
|
||||
generateNarrative(request: NarrativeRequest): Promise<string>;
|
||||
}
|
||||
6
apps/api/src/modules/reports/domain/services/index.ts
Normal file
6
apps/api/src/modules/reports/domain/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { REPORT_GENERATOR_SERVICE, type IReportGeneratorService, type ReportGenerationResult } from './report-generator.service';
|
||||
export { MACRO_DATA_SERVICE, type IMacroDataService, type MacroDataPoint } from './macro-data.service';
|
||||
export { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService, type InfrastructureProjectData } from './infrastructure-data.service';
|
||||
export { AI_NARRATIVE_SERVICE, type IAINarrativeService, type NarrativeRequest } from './ai-narrative.service';
|
||||
export { PDF_GENERATOR_SERVICE, type IPdfGeneratorService } from './pdf-generator.service';
|
||||
export { PDF_STORAGE_SERVICE, type IPdfStorageService } from './pdf-storage.service';
|
||||
@@ -0,0 +1,18 @@
|
||||
export const INFRASTRUCTURE_DATA_SERVICE = Symbol('INFRASTRUCTURE_DATA_SERVICE');
|
||||
|
||||
export interface InfrastructureProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
province: string;
|
||||
category: string;
|
||||
status: string;
|
||||
investmentVND: bigint | null;
|
||||
startDate: Date | null;
|
||||
completionDate: Date | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface IInfrastructureDataService {
|
||||
getByProvince(province: string, category?: string): Promise<InfrastructureProjectData[]>;
|
||||
getByStatus(status: string): Promise<InfrastructureProjectData[]>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const MACRO_DATA_SERVICE = Symbol('MACRO_DATA_SERVICE');
|
||||
|
||||
export interface MacroDataPoint {
|
||||
province: string;
|
||||
indicator: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
period: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface IMacroDataService {
|
||||
getByProvince(province: string, indicators?: string[]): Promise<MacroDataPoint[]>;
|
||||
getByIndicator(indicator: string, provinces?: string[]): Promise<MacroDataPoint[]>;
|
||||
upsert(data: Omit<MacroDataPoint, 'source'> & { source: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PDF_GENERATOR_SERVICE = Symbol('PDF_GENERATOR_SERVICE');
|
||||
|
||||
export interface IPdfGeneratorService {
|
||||
generatePdf(reportId: string, content: Record<string, unknown>): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PDF_STORAGE_SERVICE = Symbol('PDF_STORAGE_SERVICE');
|
||||
|
||||
export interface IPdfStorageService {
|
||||
uploadPdf(buffer: Buffer, reportId: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const REPORT_GENERATOR_SERVICE = Symbol('REPORT_GENERATOR_SERVICE');
|
||||
|
||||
export interface ReportGenerationResult {
|
||||
content: Record<string, unknown>;
|
||||
pdfUrl: string | null;
|
||||
}
|
||||
|
||||
export interface IReportGeneratorService {
|
||||
generate(reportId: string): Promise<void>;
|
||||
}
|
||||
4
apps/api/src/modules/reports/index.ts
Normal file
4
apps/api/src/modules/reports/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ReportsModule } from './reports.module';
|
||||
export { REPORT_REPOSITORY, type IReportRepository } from './domain/repositories/report.repository';
|
||||
export { MACRO_DATA_SERVICE, type IMacroDataService } from './domain/services/macro-data.service';
|
||||
export { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService } from './domain/services/infrastructure-data.service';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Prisma, type Report as PrismaReport } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ReportEntity } from '../../domain/entities/report.entity';
|
||||
import { type ReportStatus } from '../../domain/enums/report-status.enum';
|
||||
import { type ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { type IReportRepository, type ListReportsFilter } from '../../domain/repositories/report.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaReportRepository implements IReportRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<ReportEntity | null> {
|
||||
const row = await this.prisma.report.findUnique({ where: { id } });
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByUserId(filter: ListReportsFilter): Promise<{ reports: ReportEntity[]; total: number }> {
|
||||
const where: Record<string, unknown> = { userId: filter.userId };
|
||||
if (filter.type) where['type'] = filter.type;
|
||||
if (filter.status) where['status'] = filter.status;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.report.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: filter.limit ?? 20,
|
||||
skip: filter.offset ?? 0,
|
||||
}),
|
||||
this.prisma.report.count({ where }),
|
||||
]);
|
||||
|
||||
return { reports: rows.map((r) => this.toDomain(r)), total };
|
||||
}
|
||||
|
||||
async save(entity: ReportEntity): Promise<void> {
|
||||
await this.prisma.report.create({
|
||||
data: {
|
||||
id: entity.id,
|
||||
userId: entity.userId,
|
||||
type: entity.type as string as PrismaReport['type'],
|
||||
title: entity.title,
|
||||
params: entity.params as Prisma.InputJsonValue,
|
||||
content: entity.content as Prisma.InputJsonValue ?? undefined,
|
||||
pdfUrl: entity.pdfUrl,
|
||||
status: entity.status as string as PrismaReport['status'],
|
||||
errorMsg: entity.errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(entity: ReportEntity): Promise<void> {
|
||||
await this.prisma.report.update({
|
||||
where: { id: entity.id },
|
||||
data: {
|
||||
content: entity.content as Prisma.InputJsonValue ?? undefined,
|
||||
pdfUrl: entity.pdfUrl,
|
||||
status: entity.status as string as PrismaReport['status'],
|
||||
errorMsg: entity.errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.report.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async countByUserInPeriod(userId: string, periodStart: Date, periodEnd: Date): Promise<number> {
|
||||
return this.prisma.report.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: periodStart, lt: periodEnd },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(row: PrismaReport): ReportEntity {
|
||||
return new ReportEntity(
|
||||
row.id,
|
||||
{
|
||||
userId: row.userId,
|
||||
type: row.type as string as ReportType,
|
||||
title: row.title,
|
||||
params: row.params as Record<string, unknown>,
|
||||
content: row.content as Record<string, unknown> | null,
|
||||
pdfUrl: row.pdfUrl,
|
||||
status: row.status as string as ReportStatus,
|
||||
errorMsg: row.errorMsg,
|
||||
},
|
||||
row.createdAt,
|
||||
row.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
type IAINarrativeService,
|
||||
type NarrativeRequest,
|
||||
} from '../../domain/services/ai-narrative.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeNarrativeService implements IAINarrativeService {
|
||||
private readonly logger = new Logger(ClaudeNarrativeService.name);
|
||||
private readonly client: Anthropic | null;
|
||||
private readonly model = 'claude-sonnet-4-20250514';
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const apiKey = this.config.get<string>('CLAUDE_API_KEY');
|
||||
this.client = apiKey ? new Anthropic({ apiKey }) : null;
|
||||
|
||||
if (!this.client) {
|
||||
this.logger.warn('CLAUDE_API_KEY not configured — AI narratives will use fallback templates.');
|
||||
}
|
||||
}
|
||||
|
||||
async generateNarrative(request: NarrativeRequest): Promise<string> {
|
||||
if (!this.client) {
|
||||
return this.fallbackNarrative(request);
|
||||
}
|
||||
|
||||
const systemPrompt = this.buildSystemPrompt(request);
|
||||
const userPrompt = this.buildUserPrompt(request);
|
||||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n\n');
|
||||
|
||||
return text || this.fallbackNarrative(request);
|
||||
} catch (error) {
|
||||
this.logger.error(`Claude API error for ${request.sectionKey}: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
return this.fallbackNarrative(request);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(request: NarrativeRequest): string {
|
||||
const locale = request.locale ?? 'vi';
|
||||
return [
|
||||
'You are an expert Vietnamese real estate market analyst.',
|
||||
`Write in ${locale === 'vi' ? 'Vietnamese' : 'English'}.`,
|
||||
'Produce concise, data-driven analysis suitable for investors and professionals.',
|
||||
'Use bullet points and short paragraphs. Include specific numbers from the provided data.',
|
||||
'Do not repeat raw JSON — interpret the data and provide actionable insights.',
|
||||
'Format output as plain text (no markdown headers).',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
private buildUserPrompt(request: NarrativeRequest): string {
|
||||
const sectionPrompts: Record<string, string> = {
|
||||
executive_summary: `Write an executive summary for a ${request.reportType} report. Highlight 3-5 key findings from the data.`,
|
||||
risk_assessment: 'Analyze investment risks based on the economic indicators and infrastructure data. Cover: market risk, regulatory risk, liquidity risk, and macro risk.',
|
||||
recommendation: 'Provide 3-5 investment recommendations with specific rationale. Include suggested strategies and risk mitigation.',
|
||||
forecast: 'Provide a 6-12 month market forecast based on macro trends, supply-demand dynamics, and infrastructure pipeline.',
|
||||
industrial_landscape: 'Analyze the industrial park landscape: occupancy trends, rental rates, key tenants, and competitive positioning.',
|
||||
market_overview: 'Provide a market overview covering transaction volume, price trends, and key drivers for the period.',
|
||||
price_analysis: 'Analyze pricing trends, distribution by segment, and identify top-performing areas.',
|
||||
supply_demand: 'Analyze supply-demand balance: new inventory, absorption rate, and days-on-market trends.',
|
||||
};
|
||||
|
||||
const instruction = sectionPrompts[request.sectionKey]
|
||||
?? `Write analysis for the "${request.sectionTitle}" section of a ${request.reportType} report.`;
|
||||
|
||||
return `${instruction}\n\nData context:\n${JSON.stringify(request.context, null, 2)}`;
|
||||
}
|
||||
|
||||
private fallbackNarrative(request: NarrativeRequest): string {
|
||||
return `[${request.sectionTitle}] Phân tích tự động tạm thời không khả dụng. Dữ liệu thô có sẵn trong phần dữ liệu bên dưới.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type IInfrastructureDataService, type InfrastructureProjectData } from '../../domain/services/infrastructure-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaInfrastructureDataService implements IInfrastructureDataService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getByProvince(province: string, category?: string): Promise<InfrastructureProjectData[]> {
|
||||
const where: Record<string, unknown> = { province };
|
||||
if (category) where['category'] = category;
|
||||
|
||||
const rows = await this.prisma.infrastructureProject.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
province: r.province,
|
||||
category: r.category,
|
||||
status: r.status,
|
||||
investmentVND: r.investmentVND,
|
||||
startDate: r.startDate,
|
||||
completionDate: r.completionDate,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
|
||||
async getByStatus(status: string): Promise<InfrastructureProjectData[]> {
|
||||
const rows = await this.prisma.infrastructureProject.findMany({
|
||||
where: { status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
province: r.province,
|
||||
category: r.category,
|
||||
status: r.status,
|
||||
investmentVND: r.investmentVND,
|
||||
startDate: r.startDate,
|
||||
completionDate: r.completionDate,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { type IMacroDataService, type MacroDataPoint } from '../../domain/services/macro-data.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaMacroDataService implements IMacroDataService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getByProvince(province: string, indicators?: string[]): Promise<MacroDataPoint[]> {
|
||||
const where: Record<string, unknown> = { province };
|
||||
if (indicators?.length) {
|
||||
where['indicator'] = { in: indicators };
|
||||
}
|
||||
|
||||
const rows = await this.prisma.macroeconomicData.findMany({
|
||||
where,
|
||||
orderBy: [{ indicator: 'asc' }, { period: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
province: r.province,
|
||||
indicator: r.indicator,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
period: r.period,
|
||||
source: r.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async getByIndicator(indicator: string, provinces?: string[]): Promise<MacroDataPoint[]> {
|
||||
const where: Record<string, unknown> = { indicator };
|
||||
if (provinces?.length) {
|
||||
where['province'] = { in: provinces };
|
||||
}
|
||||
|
||||
const rows = await this.prisma.macroeconomicData.findMany({
|
||||
where,
|
||||
orderBy: [{ province: 'asc' }, { period: 'desc' }],
|
||||
});
|
||||
|
||||
return rows.map((r) => ({
|
||||
province: r.province,
|
||||
indicator: r.indicator,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
period: r.period,
|
||||
source: r.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsert(data: MacroDataPoint): Promise<void> {
|
||||
await this.prisma.macroeconomicData.upsert({
|
||||
where: {
|
||||
province_indicator_period: {
|
||||
province: data.province,
|
||||
indicator: data.indicator,
|
||||
period: data.period,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
province: data.province,
|
||||
indicator: data.indicator,
|
||||
value: data.value,
|
||||
unit: data.unit,
|
||||
period: data.period,
|
||||
source: data.source,
|
||||
},
|
||||
update: {
|
||||
value: data.value,
|
||||
unit: data.unit,
|
||||
source: data.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MinioPdfStorageService implements IPdfStorageService {
|
||||
private readonly logger = new Logger(MinioPdfStorageService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly publicBaseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
|
||||
const port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
|
||||
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||
const protocol = useSSL ? 'https' : 'http';
|
||||
|
||||
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
|
||||
this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`;
|
||||
|
||||
this.s3 = new S3Client({
|
||||
endpoint: `${protocol}://${endpoint}:${port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env['MINIO_ACCESS_KEY'] || '',
|
||||
secretAccessKey: process.env['MINIO_SECRET_KEY'] || '',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
|
||||
const objectKey = `reports/${reportId}.pdf`;
|
||||
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: objectKey,
|
||||
Body: buffer,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: `inline; filename="report-${reportId}.pdf"`,
|
||||
}),
|
||||
);
|
||||
|
||||
const url = `${this.publicBaseUrl}/${objectKey}`;
|
||||
this.logger.log(`PDF uploaded: ${url}`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
|
||||
/** Vietnamese report type display names */
|
||||
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
RESIDENTIAL_MARKET: 'Thị trường nhà ở',
|
||||
INDUSTRIAL_MARKET: 'Thị trường công nghiệp',
|
||||
DISTRICT_ANALYSIS: 'Phân tích quận/huyện',
|
||||
INVESTMENT_FEASIBILITY: 'Phân tích khả thi đầu tư',
|
||||
INDUSTRIAL_LOCATION: 'Vị trí khu công nghiệp',
|
||||
PROPERTY_VALUATION: 'Định giá bất động sản',
|
||||
PORTFOLIO: 'Danh mục đầu tư',
|
||||
};
|
||||
|
||||
interface ChartDataPoint {
|
||||
period: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface ReportSection {
|
||||
title?: string;
|
||||
content?: string;
|
||||
data?: Record<string, unknown>;
|
||||
charts?: Record<string, ChartDataPoint[]>;
|
||||
projects?: Array<Record<string, unknown>>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PuppeteerPdfGenerationService implements IPdfGenerationService {
|
||||
private readonly logger = new Logger(PuppeteerPdfGenerationService.name);
|
||||
|
||||
async generatePdf(
|
||||
title: string,
|
||||
reportType: string,
|
||||
content: Record<string, unknown>,
|
||||
): Promise<Buffer> {
|
||||
const generatedAt = (content['generatedAt'] as string) || new Date().toISOString();
|
||||
const sections = (content['sections'] as Record<string, ReportSection>) || {};
|
||||
const typeLabel = REPORT_TYPE_LABELS[reportType] || reportType;
|
||||
|
||||
const html = this.buildHtml(title, typeLabel, generatedAt, sections);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: `
|
||||
<div style="font-family: 'Be Vietnam Pro', Arial, sans-serif; font-size: 9px; color: #94a3b8; width: 100%; padding: 0 2cm; display: flex; justify-content: space-between;">
|
||||
<span>GoodGo AI Report — ${this.escapeHtml(title)}</span>
|
||||
<span>Trang <span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
this.logger.log(`PDF generated for report: ${title} (${pdfBuffer.length} bytes)`);
|
||||
return Buffer.from(pdfBuffer);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(
|
||||
title: string,
|
||||
typeLabel: string,
|
||||
generatedAt: string,
|
||||
sections: Record<string, ReportSection>,
|
||||
): string {
|
||||
const date = new Date(generatedAt);
|
||||
const formattedDate = date.toLocaleDateString('vi-VN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const sectionEntries = Object.entries(sections);
|
||||
const tocHtml = this.buildToc(sectionEntries);
|
||||
const sectionsHtml = sectionEntries
|
||||
.map(([key, section], index) => this.buildSection(key, section, index + 1))
|
||||
.join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Be Vietnam Pro', Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── Cover page ── */
|
||||
.cover {
|
||||
page-break-after: always;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 85vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cover-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.cover-logo svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 12px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cover-type {
|
||||
font-size: 14pt;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cover-date {
|
||||
font-size: 11pt;
|
||||
color: #64748b;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 20px;
|
||||
padding: 8px 20px;
|
||||
font-size: 10pt;
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.ai-badge svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: #059669;
|
||||
}
|
||||
|
||||
/* ── TOC ── */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
}
|
||||
|
||||
.toc-num {
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.section-num {
|
||||
font-size: 14pt;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-content p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Charts ── */
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Data tables ── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f1f5f9;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* ── Methodology page ── */
|
||||
.methodology {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.methodology h2 {
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.methodology h3 {
|
||||
font-size: 12pt;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.methodology p, .methodology li {
|
||||
color: #475569;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.methodology ul {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.methodology li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.contact-box {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 24px;
|
||||
padding: 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
font-size: 9pt;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══ COVER PAGE ═══ -->
|
||||
<div class="cover">
|
||||
<div class="cover-logo">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<h1>${this.escapeHtml(title)}</h1>
|
||||
<div class="cover-type">${this.escapeHtml(typeLabel)}</div>
|
||||
<div class="cover-date">${this.escapeHtml(formattedDate)}</div>
|
||||
<div class="ai-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 017 7h1.27c.34-.6.99-1 1.73-1a2 2 0 010 4c-.74 0-1.39-.4-1.73-1H21a7 7 0 01-7 7v1.27c.6.34 1 .99 1 1.73a2 2 0 01-4 0c0-.74.4-1.39 1-1.73V23a7 7 0 01-7-7H3.73c-.34.6-.99 1-1.73 1a2 2 0 010-4c.74 0 1.39.4 1.73 1H5a7 7 0 017-7V5.73C11.4 5.39 11 4.74 11 4a2 2 0 012-2z"/></svg>
|
||||
Powered by AI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ TABLE OF CONTENTS ═══ -->
|
||||
<div class="toc">
|
||||
<h2>Mục lục</h2>
|
||||
${tocHtml}
|
||||
</div>
|
||||
|
||||
<!-- ═══ EXECUTIVE SUMMARY (first section, forced to its own page) ═══ -->
|
||||
${sectionsHtml}
|
||||
|
||||
<!-- ═══ METHODOLOGY & SOURCES ═══ -->
|
||||
<div class="methodology">
|
||||
<h2>Phương pháp & Nguồn dữ liệu</h2>
|
||||
|
||||
<h3>Phương pháp phân tích</h3>
|
||||
<ul>
|
||||
<li>Phân tích dữ liệu thị trường BĐS từ các sàn giao dịch lớn</li>
|
||||
<li>Mô hình định giá tự động (AVM) dựa trên machine learning</li>
|
||||
<li>Phân tích chuỗi thời gian cho dự báo xu hướng giá</li>
|
||||
<li>Xử lý ngôn ngữ tự nhiên (NLP) cho phân tích tin tức và sentiment</li>
|
||||
<li>Dữ liệu vĩ mô từ Tổng cục Thống kê và các nguồn chính thức</li>
|
||||
</ul>
|
||||
|
||||
<h3>Nguồn dữ liệu</h3>
|
||||
<ul>
|
||||
<li>GoodGo Platform — dữ liệu listings và giao dịch nội bộ</li>
|
||||
<li>Tổng cục Thống kê Việt Nam (GSO)</li>
|
||||
<li>Ngân hàng Nhà nước Việt Nam (SBV)</li>
|
||||
<li>Bộ Kế hoạch và Đầu tư — dữ liệu FDI</li>
|
||||
<li>OpenStreetMap & Google Maps — dữ liệu POI</li>
|
||||
</ul>
|
||||
|
||||
<h3>Liên hệ</h3>
|
||||
<div class="contact-box">
|
||||
<p><strong>GoodGo AI Research</strong></p>
|
||||
<p>Email: research@goodgo.vn</p>
|
||||
<p>Website: goodgo.vn</p>
|
||||
</div>
|
||||
|
||||
<div class="disclaimer">
|
||||
<strong>Miễn trừ trách nhiệm:</strong> Báo cáo này được tạo tự động bởi hệ thống AI của GoodGo và chỉ mang
|
||||
tính tham khảo. Các dữ liệu và phân tích không thay thế cho tư vấn chuyên nghiệp. GoodGo không chịu trách
|
||||
nhiệm cho bất kỳ quyết định đầu tư nào dựa trên nội dung báo cáo này.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildToc(entries: Array<[string, ReportSection]>): string {
|
||||
const items = entries
|
||||
.map(([, section], index) => {
|
||||
const title = section.title || 'Untitled';
|
||||
return `<li><span class="toc-num">${index + 1}.</span><span class="toc-title">${this.escapeHtml(title)}</span></li>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<ul class="toc-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
private buildSection(key: string, section: ReportSection, index: number): string {
|
||||
const title = section.title || key;
|
||||
const isExecutiveSummary = key === 'executive_summary';
|
||||
|
||||
let html = `<div class="section" ${isExecutiveSummary ? 'style="page-break-before: always;"' : ''}>`;
|
||||
html += `<div class="section-header">`;
|
||||
html += `<span class="section-num">${index}</span>`;
|
||||
html += `<span class="section-title">${this.escapeHtml(title)}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Text content
|
||||
if (section.content) {
|
||||
html += `<div class="section-content"><p>${this.escapeHtml(section.content)}</p></div>`;
|
||||
}
|
||||
|
||||
// Charts
|
||||
if (section.charts) {
|
||||
for (const [chartKey, chartData] of Object.entries(section.charts)) {
|
||||
if (Array.isArray(chartData) && chartData.length > 0) {
|
||||
html += this.buildChart(chartKey, chartData as ChartDataPoint[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data tables (from macro data or similar)
|
||||
if (section.data && typeof section.data === 'object') {
|
||||
html += this.buildDataTables(section.data as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Infrastructure projects table
|
||||
if (section.projects && Array.isArray(section.projects)) {
|
||||
html += this.buildProjectsTable(section.projects);
|
||||
}
|
||||
|
||||
// Summary counts
|
||||
if (section.summary && typeof section.summary === 'object') {
|
||||
html += this.buildSummary(section.summary as Record<string, unknown>);
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildChart(chartKey: string, data: ChartDataPoint[]): string {
|
||||
const chartLabel = chartKey
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const unit = data[0]?.unit || '';
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const maxVal = Math.max(...values, 1);
|
||||
const minVal = Math.min(...values, 0);
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
const width = 500;
|
||||
const height = 200;
|
||||
const paddingLeft = 60;
|
||||
const paddingRight = 20;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 40;
|
||||
const chartWidth = width - paddingLeft - paddingRight;
|
||||
const chartHeight = height - paddingTop - paddingBottom;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = paddingLeft + (i / Math.max(data.length - 1, 1)) * chartWidth;
|
||||
const y = paddingTop + chartHeight - ((d.value - minVal) / range) * chartHeight;
|
||||
return { x, y, period: d.period, value: d.value };
|
||||
});
|
||||
|
||||
// Build SVG line chart
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
const areaD = pathD + ` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
|
||||
|
||||
// Y-axis labels (5 ticks)
|
||||
const yLabels = Array.from({ length: 5 }, (_, i) => {
|
||||
const val = minVal + (range * i) / 4;
|
||||
const y = paddingTop + chartHeight - (i / 4) * chartHeight;
|
||||
return { val, y };
|
||||
});
|
||||
|
||||
// X-axis labels (show max 6)
|
||||
const step = Math.max(1, Math.ceil(data.length / 6));
|
||||
const xLabels = data
|
||||
.filter((_, i) => i % step === 0 || i === data.length - 1)
|
||||
.map((d, _idx, _arr) => {
|
||||
const originalIdx = data.indexOf(d);
|
||||
const x = paddingLeft + (originalIdx / Math.max(data.length - 1, 1)) * chartWidth;
|
||||
return { x, label: d.period };
|
||||
});
|
||||
|
||||
const formatNum = (n: number): string => {
|
||||
if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
||||
if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||
return n.toFixed(1);
|
||||
};
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
||||
<!-- Grid lines -->
|
||||
${yLabels.map((yl) => `<line x1="${paddingLeft}" y1="${yl.y.toFixed(1)}" x2="${width - paddingRight}" y2="${yl.y.toFixed(1)}" stroke="#e2e8f0" stroke-width="1"/>`).join('\n')}
|
||||
|
||||
<!-- Area fill -->
|
||||
<path d="${areaD}" fill="url(#grad-${chartKey})" opacity="0.3"/>
|
||||
|
||||
<!-- Gradient -->
|
||||
<defs>
|
||||
<linearGradient id="grad-${chartKey}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#10b981"/>
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Line -->
|
||||
<path d="${pathD}" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Data points -->
|
||||
${points.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="#059669" stroke="white" stroke-width="1.5"/>`).join('\n')}
|
||||
|
||||
<!-- Y-axis labels -->
|
||||
${yLabels.map((yl) => `<text x="${paddingLeft - 8}" y="${(yl.y + 4).toFixed(1)}" text-anchor="end" font-size="9" fill="#64748b" font-family="Be Vietnam Pro, Arial">${formatNum(yl.val)}</text>`).join('\n')}
|
||||
|
||||
<!-- X-axis labels -->
|
||||
${xLabels.map((xl) => `<text x="${xl.x.toFixed(1)}" y="${(paddingTop + chartHeight + 20).toFixed(1)}" text-anchor="middle" font-size="8" fill="#64748b" font-family="Be Vietnam Pro, Arial">${this.escapeHtml(xl.label)}</text>`).join('\n')}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">${this.escapeHtml(chartLabel)}${unit ? ` (${this.escapeHtml(unit)})` : ''}</div>
|
||||
${svg}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private buildDataTables(data: Record<string, unknown>): string {
|
||||
let html = '';
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (!Array.isArray(val) || val.length === 0) continue;
|
||||
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const items = val as ChartDataPoint[];
|
||||
|
||||
html += `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3">${this.escapeHtml(label)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kỳ</th>
|
||||
<th>Giá trị</th>
|
||||
<th>Đơn vị</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((item) => `
|
||||
<tr>
|
||||
<td>${this.escapeHtml(String(item.period))}</td>
|
||||
<td>${typeof item.value === 'number' ? item.value.toLocaleString('vi-VN') : String(item.value)}</td>
|
||||
<td>${this.escapeHtml(String(item.unit || ''))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildProjectsTable(projects: Array<Record<string, unknown>>): string {
|
||||
if (projects.length === 0) return '';
|
||||
|
||||
return `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dự án</th>
|
||||
<th>Danh mục</th>
|
||||
<th>Trạng thái</th>
|
||||
<th>Vốn đầu tư (VND)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${projects.map((p) => `
|
||||
<tr>
|
||||
<td>${this.escapeHtml(String(p['name'] || ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['category'] || ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['status'] || ''))}</td>
|
||||
<td>${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') : '—'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
private buildSummary(summary: Record<string, unknown>): string {
|
||||
let html = '<div class="section-content">';
|
||||
|
||||
for (const [key, val] of Object.entries(summary)) {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
if (typeof val === 'number') {
|
||||
html += `<p><strong>${this.escapeHtml(label)}:</strong> ${val.toLocaleString('vi-VN')}</p>`;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
html += `<p><strong>${this.escapeHtml(label)}:</strong></p>`;
|
||||
html += '<ul style="margin-left: 20px; margin-bottom: 8px;">';
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
html += `<li>${this.escapeHtml(k)}: ${String(v)}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class MinioPdfStorageService implements IPdfStorageService {
|
||||
private readonly logger = new Logger(MinioPdfStorageService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly endpoint: string;
|
||||
private readonly port: number;
|
||||
private readonly bucket: string;
|
||||
private readonly useSSL: boolean;
|
||||
|
||||
constructor() {
|
||||
const accessKey = process.env['MINIO_ACCESS_KEY'];
|
||||
const secretKey = process.env['MINIO_SECRET_KEY'];
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error('Missing MINIO_ACCESS_KEY or MINIO_SECRET_KEY environment variables');
|
||||
}
|
||||
|
||||
this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
|
||||
this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
|
||||
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
|
||||
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
|
||||
this.s3 = new S3Client({
|
||||
endpoint: `${protocol}://${this.endpoint}:${this.port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
|
||||
const objectKey = `reports/${reportId}/${Date.now()}-report.pdf`;
|
||||
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: objectKey,
|
||||
Body: buffer,
|
||||
ContentType: 'application/pdf',
|
||||
ContentDisposition: 'inline',
|
||||
}),
|
||||
);
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
|
||||
|
||||
this.logger.log(`PDF uploaded: ${objectKey} (${buffer.length} bytes)`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
|
||||
interface ReportSection {
|
||||
title: string;
|
||||
content?: string;
|
||||
data?: Record<string, unknown>;
|
||||
projects?: Array<Record<string, unknown>>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PuppeteerPdfService implements IPdfGeneratorService {
|
||||
private readonly logger = new Logger(PuppeteerPdfService.name);
|
||||
|
||||
async generatePdf(reportId: string, content: Record<string, unknown>): Promise<string> {
|
||||
const html = this.buildHtml(content);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfPath = `/tmp/report-${reportId}.pdf`;
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
|
||||
printBackground: true,
|
||||
});
|
||||
|
||||
this.logger.log(`PDF generated: ${pdfPath}`);
|
||||
return pdfPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(content: Record<string, unknown>): string {
|
||||
const reportType = content['reportType'] as string ?? 'Báo cáo';
|
||||
const province = (content['province'] ?? content['city'] ?? '') as string;
|
||||
const generatedAt = content['generatedAt'] as string ?? new Date().toISOString();
|
||||
const sections = content['sections'] as Record<string, ReportSection> | undefined;
|
||||
|
||||
const sectionHtml = sections
|
||||
? Object.entries(sections).map(([key, section]) => this.renderSection(key, section)).join('\n')
|
||||
: '<p>Không có dữ liệu.</p>';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #2563eb;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header h1 { font-size: 18pt; color: #2563eb; margin-bottom: 4px; }
|
||||
.header .meta { font-size: 9pt; color: #666; }
|
||||
.section { margin-bottom: 20px; page-break-inside: avoid; }
|
||||
.section h2 {
|
||||
font-size: 13pt;
|
||||
color: #2563eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section p { margin-bottom: 8px; white-space: pre-line; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
th, td { border: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; }
|
||||
th { background: #f3f4f6; font-weight: 600; }
|
||||
.data-grid { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; }
|
||||
.data-card {
|
||||
flex: 1 1 200px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.data-card h4 { font-size: 9pt; color: #666; margin-bottom: 4px; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 8px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>${this.escapeHtml(reportType)} — ${this.escapeHtml(province)}</h1>
|
||||
<div class="meta">GoodGo Platform AI | Ngày tạo: ${new Date(generatedAt).toLocaleDateString('vi-VN')}</div>
|
||||
</div>
|
||||
${sectionHtml}
|
||||
<div class="footer">
|
||||
© ${new Date().getFullYear()} GoodGo Platform — Báo cáo được tạo tự động bởi AI
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private renderSection(key: string, section: ReportSection): string {
|
||||
let body = '';
|
||||
|
||||
if (section.content) {
|
||||
body += `<p>${this.escapeHtml(section.content)}</p>`;
|
||||
}
|
||||
|
||||
if (section.projects && Array.isArray(section.projects) && section.projects.length > 0) {
|
||||
body += '<table><thead><tr><th>Tên</th><th>Loại</th><th>Trạng thái</th><th>Vốn đầu tư</th></tr></thead><tbody>';
|
||||
for (const p of section.projects) {
|
||||
body += `<tr>
|
||||
<td>${this.escapeHtml(String(p['name'] ?? ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['category'] ?? ''))}</td>
|
||||
<td>${this.escapeHtml(String(p['status'] ?? ''))}</td>
|
||||
<td>${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') + ' VND' : '—'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
body += '</tbody></table>';
|
||||
}
|
||||
|
||||
if (section.data && typeof section.data === 'object' && !section.projects) {
|
||||
const entries = Object.entries(section.data);
|
||||
const firstEntry = entries[0];
|
||||
if (entries.length > 0 && firstEntry && Array.isArray(firstEntry[1])) {
|
||||
body += '<div class="data-grid">';
|
||||
for (const [indicator, values] of entries) {
|
||||
if (!Array.isArray(values) || values.length === 0) continue;
|
||||
const latest = values[values.length - 1];
|
||||
if (!latest) continue;
|
||||
const typed = latest as { period: string; value: number; unit: string };
|
||||
body += `<div class="data-card">
|
||||
<h4>${this.escapeHtml(indicator)}</h4>
|
||||
<strong>${typed.value.toLocaleString('vi-VN')} ${this.escapeHtml(typed.unit)}</strong>
|
||||
<br><span style="font-size:8pt;color:#999">${this.escapeHtml(typed.period)}</span>
|
||||
</div>`;
|
||||
}
|
||||
body += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="section" id="section-${key}"><h2>${this.escapeHtml(section.title)}</h2>${body}</div>`;
|
||||
}
|
||||
|
||||
private escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import * as fs from 'fs';
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { type Job } from 'bullmq';
|
||||
import { REPORT_GENERATION_QUEUE } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { REPORT_REPOSITORY, type IReportRepository } from '../../domain/repositories/report.repository';
|
||||
import { AI_NARRATIVE_SERVICE, type IAINarrativeService } from '../../domain/services/ai-narrative.service';
|
||||
import { INFRASTRUCTURE_DATA_SERVICE, type IInfrastructureDataService } from '../../domain/services/infrastructure-data.service';
|
||||
import { MACRO_DATA_SERVICE, type IMacroDataService } from '../../domain/services/macro-data.service';
|
||||
import { PDF_GENERATOR_SERVICE, type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
import { PDF_STORAGE_SERVICE, type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
|
||||
interface GenerateJobData {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
@Processor(REPORT_GENERATION_QUEUE)
|
||||
export class ReportGenerationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ReportGenerationProcessor.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REPORT_REPOSITORY) private readonly reportRepo: IReportRepository,
|
||||
@Inject(MACRO_DATA_SERVICE) private readonly macroData: IMacroDataService,
|
||||
@Inject(INFRASTRUCTURE_DATA_SERVICE) private readonly infraData: IInfrastructureDataService,
|
||||
@Inject(AI_NARRATIVE_SERVICE) private readonly aiNarrative: IAINarrativeService,
|
||||
@Inject(PDF_GENERATOR_SERVICE) private readonly pdfGenerator: IPdfGeneratorService,
|
||||
@Inject(PDF_STORAGE_SERVICE) private readonly pdfStorage: IPdfStorageService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<GenerateJobData>): Promise<void> {
|
||||
const { reportId } = job.data;
|
||||
this.logger.log(`Processing report generation: ${reportId}`);
|
||||
|
||||
const report = await this.reportRepo.findById(reportId);
|
||||
if (!report) {
|
||||
this.logger.warn(`Report ${reportId} not found, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.generateContent(report.type, report.params);
|
||||
|
||||
// Generate and upload PDF
|
||||
let pdfUrl: string | null = null;
|
||||
try {
|
||||
const pdfPath = await this.pdfGenerator.generatePdf(reportId, content);
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
pdfUrl = await this.pdfStorage.uploadPdf(pdfBuffer, reportId);
|
||||
fs.unlinkSync(pdfPath);
|
||||
} catch (pdfErr) {
|
||||
this.logger.warn(`PDF generation failed for ${reportId}, completing without PDF: ${pdfErr instanceof Error ? pdfErr.message : 'Unknown'}`);
|
||||
}
|
||||
|
||||
report.markReady(content, pdfUrl);
|
||||
await this.reportRepo.update(report);
|
||||
this.logger.log(`Report ${reportId} generated successfully.`);
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Report ${reportId} generation failed: ${errMsg}`);
|
||||
report.markFailed(errMsg);
|
||||
await this.reportRepo.update(report);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateContent(
|
||||
type: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (type) {
|
||||
case ReportType.INDUSTRIAL_LOCATION:
|
||||
return this.generateIndustrialLocationReport(params);
|
||||
case ReportType.RESIDENTIAL_MARKET:
|
||||
return this.generateResidentialMarketReport(params);
|
||||
case ReportType.DISTRICT_ANALYSIS:
|
||||
return this.generateDistrictAnalysisReport(params);
|
||||
default:
|
||||
return this.generateGenericReport(type, params);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateIndustrialLocationReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const province = params['province'] as string;
|
||||
|
||||
const [macroData, infraProjects] = await Promise.all([
|
||||
this.macroData.getByProvince(province, ['gdp', 'fdi', 'population', 'urbanization', 'labor_force', 'avg_wage', 'industrial_output']),
|
||||
this.infraData.getByProvince(province),
|
||||
]);
|
||||
|
||||
const macroByIndicator: Record<string, Array<{ period: string; value: number; unit: string }>> = {};
|
||||
for (const dp of macroData) {
|
||||
const arr = macroByIndicator[dp.indicator] ??= [];
|
||||
arr.push({ period: dp.period, value: dp.value, unit: dp.unit });
|
||||
}
|
||||
|
||||
const infraSerialized = infraProjects.map((p) => ({
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
status: p.status,
|
||||
investmentVND: p.investmentVND?.toString() ?? null,
|
||||
completionDate: p.completionDate?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const dataContext = { province, macroByIndicator, infraProjects: infraSerialized };
|
||||
|
||||
// Generate AI narratives for sections that need analysis
|
||||
const [executiveSummary, industrialLandscape, riskAssessment, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'industrial_landscape',
|
||||
sectionTitle: 'Thị trường KCN',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'risk_assessment',
|
||||
sectionTitle: 'Đánh giá rủi ro',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị đầu tư',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.INDUSTRIAL_LOCATION,
|
||||
province,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: executiveSummary,
|
||||
},
|
||||
economic_indicators: {
|
||||
title: 'Chỉ số kinh tế',
|
||||
data: macroByIndicator,
|
||||
charts: {
|
||||
gdp_trend: macroByIndicator['gdp'] ?? [],
|
||||
fdi_trend: macroByIndicator['fdi'] ?? [],
|
||||
},
|
||||
},
|
||||
demographics: {
|
||||
title: 'Dân số & Lao động',
|
||||
data: {
|
||||
population: macroByIndicator['population'] ?? [],
|
||||
urbanization: macroByIndicator['urbanization'] ?? [],
|
||||
labor_force: macroByIndicator['labor_force'] ?? [],
|
||||
avg_wage: macroByIndicator['avg_wage'] ?? [],
|
||||
},
|
||||
},
|
||||
infrastructure: {
|
||||
title: 'Hạ tầng',
|
||||
projects: infraSerialized,
|
||||
summary: {
|
||||
total: infraProjects.length,
|
||||
byCategory: this.groupBy(infraProjects as unknown as Array<Record<string, unknown>>, 'category'),
|
||||
byStatus: this.groupBy(infraProjects as unknown as Array<Record<string, unknown>>, 'status'),
|
||||
},
|
||||
},
|
||||
industrial_landscape: {
|
||||
title: 'Thị trường KCN',
|
||||
content: industrialLandscape,
|
||||
},
|
||||
risk_assessment: {
|
||||
title: 'Đánh giá rủi ro',
|
||||
content: riskAssessment,
|
||||
},
|
||||
recommendation: {
|
||||
title: 'Khuyến nghị đầu tư',
|
||||
content: recommendation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateResidentialMarketReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const city = params['city'] as string;
|
||||
const period = params['period'] as string;
|
||||
|
||||
const macroData = await this.macroData.getByProvince(city, ['gdp', 'cpi', 'mortgage_rate', 'fdi', 'population']);
|
||||
|
||||
const macroByIndicator: Record<string, Array<{ period: string; value: number; unit: string }>> = {};
|
||||
for (const dp of macroData) {
|
||||
const arr = macroByIndicator[dp.indicator] ??= [];
|
||||
arr.push({ period: dp.period, value: dp.value, unit: dp.unit });
|
||||
}
|
||||
|
||||
const dataContext = { city, period, macroByIndicator };
|
||||
|
||||
const [executiveSummary, marketOverview, priceAnalysis, supplyDemand, forecast, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt thị trường',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'market_overview',
|
||||
sectionTitle: 'Tổng quan',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'price_analysis',
|
||||
sectionTitle: 'Phân tích giá',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'supply_demand',
|
||||
sectionTitle: 'Cung cầu',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'forecast',
|
||||
sectionTitle: 'Dự báo',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.RESIDENTIAL_MARKET,
|
||||
city,
|
||||
period,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt thị trường',
|
||||
content: executiveSummary,
|
||||
},
|
||||
market_overview: {
|
||||
title: 'Tổng quan',
|
||||
content: marketOverview,
|
||||
},
|
||||
price_analysis: {
|
||||
title: 'Phân tích giá',
|
||||
content: priceAnalysis,
|
||||
},
|
||||
supply_demand: {
|
||||
title: 'Cung cầu',
|
||||
content: supplyDemand,
|
||||
},
|
||||
macro_impact: {
|
||||
title: 'Tác động vĩ mô',
|
||||
data: macroByIndicator,
|
||||
},
|
||||
forecast: {
|
||||
title: 'Dự báo',
|
||||
content: forecast,
|
||||
},
|
||||
recommendation: {
|
||||
title: 'Khuyến nghị',
|
||||
content: recommendation,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateDistrictAnalysisReport(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const city = params['city'] as string;
|
||||
const district = params['district'] as string;
|
||||
|
||||
const dataContext = { city, district };
|
||||
|
||||
const [executiveSummary, marketData, neighborhood, infrastructure, recommendation] = await Promise.all([
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'market_overview',
|
||||
sectionTitle: 'Dữ liệu thị trường',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'supply_demand',
|
||||
sectionTitle: 'Khu vực lân cận',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'industrial_landscape',
|
||||
sectionTitle: 'Hạ tầng',
|
||||
context: dataContext,
|
||||
}),
|
||||
this.aiNarrative.generateNarrative({
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
sectionKey: 'recommendation',
|
||||
sectionTitle: 'Khuyến nghị',
|
||||
context: dataContext,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
reportType: ReportType.DISTRICT_ANALYSIS,
|
||||
city,
|
||||
district,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: executiveSummary,
|
||||
},
|
||||
market_data: { title: 'Dữ liệu thị trường', content: marketData },
|
||||
neighborhood: { title: 'Khu vực lân cận', content: neighborhood },
|
||||
infrastructure: { title: 'Hạ tầng', content: infrastructure },
|
||||
recommendation: { title: 'Khuyến nghị', content: recommendation },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async generateGenericReport(
|
||||
type: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const summary = await this.aiNarrative.generateNarrative({
|
||||
reportType: type,
|
||||
sectionKey: 'executive_summary',
|
||||
sectionTitle: 'Tóm tắt',
|
||||
context: params,
|
||||
});
|
||||
|
||||
return {
|
||||
reportType: type,
|
||||
params,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: summary,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private groupBy(items: Array<{ [key: string]: unknown }>, key: string): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const item of items) {
|
||||
const val = String(item[key]);
|
||||
result[val] = (result[val] ?? 0) + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth';
|
||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||
import { DeleteReportCommand } from '../../application/commands/delete-report/delete-report.command';
|
||||
import { GenerateReportCommand } from '../../application/commands/generate-report/generate-report.command';
|
||||
import { type GenerateReportResult } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { GetReportQuery } from '../../application/queries/get-report/get-report.query';
|
||||
import { type ListReportsResult } from '../../application/queries/list-reports/list-reports.handler';
|
||||
import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query';
|
||||
import { type ReportEntity } from '../../domain/entities/report.entity';
|
||||
import { type GenerateReportDto } from '../dto/generate-report.dto';
|
||||
import { type ListReportsDto } from '../dto/list-reports.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { sub: string };
|
||||
}
|
||||
|
||||
@ApiTags('reports')
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@Post('generate')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||
@RequireQuota('reports_generated')
|
||||
@ApiOperation({ summary: 'Tạo báo cáo AI (async)' })
|
||||
@ApiResponse({ status: 201, description: 'Report generation started' })
|
||||
async generate(
|
||||
@Body() dto: GenerateReportDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<GenerateReportResult> {
|
||||
return this.commandBus.execute(
|
||||
new GenerateReportCommand(req.user.sub, dto.type, dto.title, dto.params),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Danh sách báo cáo của tôi' })
|
||||
@ApiResponse({ status: 200, description: 'List of reports' })
|
||||
async list(
|
||||
@Query() dto: ListReportsDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result: ListReportsResult = await this.queryBus.execute(
|
||||
new ListReportsQuery(req.user.sub, dto.type, dto.limit, dto.offset),
|
||||
);
|
||||
return {
|
||||
data: result.reports.map((r) => this.toResponse(r)),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Chi tiết báo cáo' })
|
||||
@ApiResponse({ status: 200, description: 'Report details' })
|
||||
async getById(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const report: ReportEntity = await this.queryBus.execute(
|
||||
new GetReportQuery(id, req.user.sub),
|
||||
);
|
||||
return this.toResponse(report);
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Trạng thái tạo báo cáo' })
|
||||
@ApiResponse({ status: 200, description: 'Report status' })
|
||||
async getStatus(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const report: ReportEntity = await this.queryBus.execute(
|
||||
new GetReportQuery(id, req.user.sub),
|
||||
);
|
||||
return {
|
||||
id: report.id,
|
||||
status: report.status,
|
||||
errorMsg: report.errorMsg,
|
||||
pdfUrl: report.pdfUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Xóa báo cáo' })
|
||||
@ApiResponse({ status: 204, description: 'Report deleted' })
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<void> {
|
||||
await this.commandBus.execute(new DeleteReportCommand(id, req.user.sub));
|
||||
}
|
||||
|
||||
private toResponse(report: ReportEntity) {
|
||||
return {
|
||||
id: report.id,
|
||||
type: report.type,
|
||||
title: report.title,
|
||||
params: report.params,
|
||||
content: report.content,
|
||||
pdfUrl: report.pdfUrl,
|
||||
status: report.status,
|
||||
errorMsg: report.errorMsg,
|
||||
createdAt: report.createdAt.toISOString(),
|
||||
updatedAt: report.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsEnum, IsNotEmpty, IsObject, IsString } from 'class-validator';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
|
||||
export class GenerateReportDto {
|
||||
@IsEnum(ReportType)
|
||||
type!: ReportType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsObject()
|
||||
params!: Record<string, unknown>;
|
||||
}
|
||||
2
apps/api/src/modules/reports/presentation/dto/index.ts
Normal file
2
apps/api/src/modules/reports/presentation/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GenerateReportDto } from './generate-report.dto';
|
||||
export { ListReportsDto } from './list-reports.dto';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
|
||||
export class ListReportsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ReportType)
|
||||
type?: ReportType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number = 0;
|
||||
}
|
||||
63
apps/api/src/modules/reports/reports.module.ts
Normal file
63
apps/api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { DeleteReportHandler } from './application/commands/delete-report/delete-report.handler';
|
||||
import { GenerateReportHandler, REPORT_GENERATION_QUEUE } from './application/commands/generate-report/generate-report.handler';
|
||||
import { GetReportHandler } from './application/queries/get-report/get-report.handler';
|
||||
import { ListReportsHandler } from './application/queries/list-reports/list-reports.handler';
|
||||
import { REPORT_REPOSITORY } from './domain/repositories/report.repository';
|
||||
import { AI_NARRATIVE_SERVICE } from './domain/services/ai-narrative.service';
|
||||
import { INFRASTRUCTURE_DATA_SERVICE } from './domain/services/infrastructure-data.service';
|
||||
import { MACRO_DATA_SERVICE } from './domain/services/macro-data.service';
|
||||
import { PDF_GENERATOR_SERVICE } from './domain/services/pdf-generator.service';
|
||||
import { PDF_STORAGE_SERVICE } from './domain/services/pdf-storage.service';
|
||||
import { PrismaReportRepository } from './infrastructure/repositories/prisma-report.repository';
|
||||
import { ClaudeNarrativeService } from './infrastructure/services/claude-narrative.service';
|
||||
import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service';
|
||||
import { PrismaMacroDataService } from './infrastructure/services/macro-data.service';
|
||||
import { MinioPdfStorageService } from './infrastructure/services/minio-pdf-storage.service';
|
||||
import { PuppeteerPdfService } from './infrastructure/services/puppeteer-pdf.service';
|
||||
import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor';
|
||||
import { ReportsController } from './presentation/controllers/reports.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
GenerateReportHandler,
|
||||
DeleteReportHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetReportHandler,
|
||||
ListReportsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CqrsModule,
|
||||
ConfigModule,
|
||||
BullModule.registerQueue({
|
||||
name: REPORT_GENERATION_QUEUE,
|
||||
}),
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: REPORT_REPOSITORY, useClass: PrismaReportRepository },
|
||||
|
||||
// Services
|
||||
{ provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService },
|
||||
{ provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService },
|
||||
{ provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService },
|
||||
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfService },
|
||||
{ provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService },
|
||||
|
||||
// BullMQ processor
|
||||
ReportGenerationProcessor,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [REPORT_REPOSITORY, MACRO_DATA_SERVICE, INFRASTRUCTURE_DATA_SERVICE],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
Reference in New Issue
Block a user