feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { type PrismaService, type LoggerService } from '@modules/shared';
|
||||
|
||||
@Injectable()
|
||||
export class AvmRetrainCronService {
|
||||
private readonly aiServiceUrl: string;
|
||||
private readonly aiServiceApiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
this.aiServiceUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
|
||||
this.aiServiceApiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly retrain — every Sunday at 3 AM.
|
||||
*
|
||||
* 1. Export training data from database to the AI service
|
||||
* 2. Trigger ensemble retraining via POST /avm/v2/train
|
||||
* 3. Log results (version, metrics)
|
||||
*/
|
||||
@Cron('0 3 * * 0', { name: 'avm-v2-weekly-retrain' })
|
||||
async weeklyRetrain(): Promise<void> {
|
||||
this.logger.log('Starting weekly AVM v2 retrain...', 'AvmRetrainCronService');
|
||||
|
||||
try {
|
||||
// Step 1: Export training data
|
||||
const trainingData = await this.exportTrainingData();
|
||||
if (trainingData.length < 50) {
|
||||
this.logger.warn(
|
||||
`Insufficient training data (${trainingData.length} rows). Skipping retrain.`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Upload training data to AI service
|
||||
await this.uploadTrainingData(trainingData);
|
||||
|
||||
// Step 3: Trigger retraining
|
||||
const result = await this.triggerRetrain();
|
||||
|
||||
this.logger.log(
|
||||
`AVM v2 retrain completed: version=${result.model_version}, ` +
|
||||
`MAPE=${result.metrics?.mape ?? 'N/A'}%, ` +
|
||||
`samples=${result.training_samples}`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`AVM v2 weekly retrain failed: ${(err as Error).message}`,
|
||||
undefined,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export property + listing + market data as training rows.
|
||||
*
|
||||
* Each row maps to the feature columns expected by the Python
|
||||
* AVM v2 training pipeline (see avm_v2_service._prepare_training_data).
|
||||
*/
|
||||
async exportTrainingData(): Promise<TrainingRow[]> {
|
||||
const rows = await this.prisma.$queryRaw<RawTrainingRow[]>`
|
||||
WITH market AS (
|
||||
SELECT
|
||||
mi.district,
|
||||
mi.city,
|
||||
mi."avgPriceM2" AS avg_price_m2,
|
||||
mi."totalListings" AS listing_density,
|
||||
COALESCE(mi."absorptionRate", 0) AS absorption_rate,
|
||||
mi."daysOnMarket" AS dom_avg,
|
||||
COALESCE(mi."yoyChange", 0) AS yoy_change
|
||||
FROM "MarketIndex" mi
|
||||
WHERE mi.period = (
|
||||
SELECT MAX(period) FROM "MarketIndex"
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
p."propertyType"::text AS property_type,
|
||||
p."areaM2" AS area_m2,
|
||||
COALESCE(p.bedrooms, 2) AS rooms,
|
||||
COALESCE(p.floor, 0) AS floor_level,
|
||||
COALESCE(p."totalFloors", p.floors, 0) AS total_floors,
|
||||
COALESCE(p.direction::text, 'unknown') AS direction,
|
||||
CASE
|
||||
WHEN p."totalFloors" > 0 AND p."areaM2" > 0
|
||||
THEN (p."totalFloors"::float * p."areaM2") / NULLIF(p."areaM2", 0)
|
||||
ELSE 1.0
|
||||
END AS floor_ratio,
|
||||
CASE
|
||||
WHEN p."yearBuilt" IS NOT NULL
|
||||
THEN EXTRACT(YEAR FROM NOW())::int - p."yearBuilt"
|
||||
ELSE 5
|
||||
END AS building_age_years,
|
||||
CASE WHEN p.amenities::text ILIKE '%elevator%' THEN 1.0 ELSE 0.0 END AS has_elevator,
|
||||
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
|
||||
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
|
||||
CASE
|
||||
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0
|
||||
ELSE 0.0
|
||||
END AS has_legal_paper,
|
||||
0.5 AS developer_reputation,
|
||||
0.5 AS neighborhood_score,
|
||||
COALESCE(
|
||||
ST_Distance(
|
||||
p.location::geography,
|
||||
ST_SetSRID(ST_MakePoint(106.6297, 10.8231), 4326)::geography
|
||||
) / 1000.0,
|
||||
10.0
|
||||
) AS distance_to_cbd_km,
|
||||
COALESCE(p."metroDistanceM" / 1000.0, 5.0) AS distance_to_metro_km,
|
||||
5.0 AS distance_to_school_km,
|
||||
3.0 AS distance_to_hospital_km,
|
||||
2.0 AS distance_to_park_km,
|
||||
4.0 AS distance_to_mall_km,
|
||||
0.1 AS flood_zone_risk,
|
||||
COALESCE(m.avg_price_m2, 0) AS avg_price_district_3m_vnd_m2,
|
||||
COALESCE(m.listing_density, 0) AS listing_density,
|
||||
COALESCE(m.absorption_rate, 0) AS absorption_rate,
|
||||
COALESCE(m.dom_avg, 30) AS dom_avg,
|
||||
0.0 AS price_momentum_30d,
|
||||
COALESCE(m.yoy_change, 0) AS yoy_change,
|
||||
0.5 AS renovation_score,
|
||||
0.5 AS view_quality,
|
||||
0.5 AS interior_quality,
|
||||
0.3 AS noise_level,
|
||||
0.5 AS natural_light,
|
||||
EXTRACT(MONTH FROM l."publishedAt")::int AS month,
|
||||
p.district AS district,
|
||||
l."priceVND"::float AS price_vnd
|
||||
FROM "Listing" l
|
||||
JOIN "Property" p ON l."propertyId" = p.id
|
||||
LEFT JOIN market m ON m.district = p.district AND m.city = p.city
|
||||
WHERE l.status IN ('ACTIVE', 'SOLD', 'RENTED')
|
||||
AND l."priceVND" > 100000000
|
||||
AND l."publishedAt" IS NOT NULL
|
||||
AND p."areaM2" > 0
|
||||
ORDER BY l."publishedAt" DESC
|
||||
LIMIT 50000
|
||||
`;
|
||||
|
||||
return rows.map((r) => ({
|
||||
property_type: String(r.property_type).toLowerCase(),
|
||||
area_m2: Number(r.area_m2),
|
||||
rooms: Number(r.rooms),
|
||||
floor_level: Number(r.floor_level),
|
||||
total_floors: Number(r.total_floors),
|
||||
direction: String(r.direction).toLowerCase(),
|
||||
floor_ratio: Number(r.floor_ratio),
|
||||
building_age_years: Number(r.building_age_years),
|
||||
has_elevator: Number(r.has_elevator),
|
||||
has_parking: Number(r.has_parking),
|
||||
has_pool: Number(r.has_pool),
|
||||
has_legal_paper: Number(r.has_legal_paper),
|
||||
developer_reputation: Number(r.developer_reputation),
|
||||
neighborhood_score: Number(r.neighborhood_score),
|
||||
distance_to_cbd_km: Number(r.distance_to_cbd_km),
|
||||
distance_to_metro_km: Number(r.distance_to_metro_km),
|
||||
distance_to_school_km: Number(r.distance_to_school_km),
|
||||
distance_to_hospital_km: Number(r.distance_to_hospital_km),
|
||||
distance_to_park_km: Number(r.distance_to_park_km),
|
||||
distance_to_mall_km: Number(r.distance_to_mall_km),
|
||||
flood_zone_risk: Number(r.flood_zone_risk),
|
||||
avg_price_district_3m_vnd_m2: Number(r.avg_price_district_3m_vnd_m2),
|
||||
listing_density: Number(r.listing_density),
|
||||
absorption_rate: Number(r.absorption_rate),
|
||||
dom_avg: Number(r.dom_avg),
|
||||
price_momentum_30d: Number(r.price_momentum_30d),
|
||||
yoy_change: Number(r.yoy_change),
|
||||
renovation_score: Number(r.renovation_score),
|
||||
view_quality: Number(r.view_quality),
|
||||
interior_quality: Number(r.interior_quality),
|
||||
noise_level: Number(r.noise_level),
|
||||
natural_light: Number(r.natural_light),
|
||||
month: Number(r.month),
|
||||
district: String(r.district),
|
||||
price_vnd: Number(r.price_vnd),
|
||||
}));
|
||||
}
|
||||
|
||||
private async uploadTrainingData(rows: TrainingRow[]): Promise<void> {
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const csvLines = [headers.join(',')];
|
||||
for (const row of rows) {
|
||||
csvLines.push(headers.map((h) => String(row[h as keyof TrainingRow])).join(','));
|
||||
}
|
||||
const csv = csvLines.join('\n');
|
||||
|
||||
const url = `${this.aiServiceUrl}/avm/v2/upload-training-data`;
|
||||
const reqHeaders: Record<string, string> = { 'Content-Type': 'text/csv' };
|
||||
if (this.aiServiceApiKey) {
|
||||
reqHeaders['X-API-Key'] = this.aiServiceApiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: csv,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Training data upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Uploaded ${rows.length} training rows to AI service`,
|
||||
'AvmRetrainCronService',
|
||||
);
|
||||
}
|
||||
|
||||
private async triggerRetrain(): Promise<RetrainResult> {
|
||||
const url = `${this.aiServiceUrl}/avm/v2/train`;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.aiServiceApiKey) {
|
||||
headers['X-API-Key'] = this.aiServiceApiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
optuna_trials: 50,
|
||||
test_size: 0.15,
|
||||
val_size: 0.15,
|
||||
}),
|
||||
signal: AbortSignal.timeout(600_000), // 10 min — training can take a while
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Retrain request failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<RetrainResult>;
|
||||
}
|
||||
}
|
||||
|
||||
interface RawTrainingRow {
|
||||
property_type: string;
|
||||
area_m2: number;
|
||||
rooms: number;
|
||||
floor_level: number;
|
||||
total_floors: number;
|
||||
direction: string;
|
||||
floor_ratio: number;
|
||||
building_age_years: number;
|
||||
has_elevator: number;
|
||||
has_parking: number;
|
||||
has_pool: number;
|
||||
has_legal_paper: number;
|
||||
developer_reputation: number;
|
||||
neighborhood_score: number;
|
||||
distance_to_cbd_km: number;
|
||||
distance_to_metro_km: number;
|
||||
distance_to_school_km: number;
|
||||
distance_to_hospital_km: number;
|
||||
distance_to_park_km: number;
|
||||
distance_to_mall_km: number;
|
||||
flood_zone_risk: number;
|
||||
avg_price_district_3m_vnd_m2: number;
|
||||
listing_density: number;
|
||||
absorption_rate: number;
|
||||
dom_avg: number;
|
||||
price_momentum_30d: number;
|
||||
yoy_change: number;
|
||||
renovation_score: number;
|
||||
view_quality: number;
|
||||
interior_quality: number;
|
||||
noise_level: number;
|
||||
natural_light: number;
|
||||
month: number;
|
||||
district: string;
|
||||
price_vnd: number;
|
||||
}
|
||||
|
||||
interface TrainingRow extends RawTrainingRow {}
|
||||
|
||||
interface RetrainResult {
|
||||
model_version: string;
|
||||
metrics: {
|
||||
mae: number;
|
||||
mape: number;
|
||||
rmse: number;
|
||||
r2: number;
|
||||
};
|
||||
training_samples: number;
|
||||
validation_samples: number;
|
||||
test_samples: number;
|
||||
best_params: Record<string, unknown>;
|
||||
}
|
||||
@@ -142,6 +142,33 @@ describe('ListingEntity', () => {
|
||||
const fields = listing.updateContent({});
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should emit ListingPriceChangedEvent when price actually changes', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.clearDomainEvents();
|
||||
|
||||
listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 });
|
||||
|
||||
const events = listing.domainEvents;
|
||||
const priceEvent = events.find((e) => e.eventName === 'listing.price_changed');
|
||||
expect(priceEvent).toBeDefined();
|
||||
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).oldPrice).toBe(
|
||||
5_000_000_000n,
|
||||
);
|
||||
expect((priceEvent as { oldPrice: bigint; newPrice: bigint }).newPrice).toBe(
|
||||
6_000_000_000n,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT emit ListingPriceChangedEvent when price stays the same', () => {
|
||||
const listing = makeDefaultListing();
|
||||
listing.clearDomainEvents();
|
||||
|
||||
listing.updateContent({ priceVND: 5_000_000_000n, areaM2: 100 });
|
||||
|
||||
const events = listing.domainEvents;
|
||||
expect(events.some((e) => e.eventName === 'listing.price_changed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markEditedForReModeration', () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type PaymentType } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Emitted when an admin manually confirms a VN bank transfer payment.
|
||||
*
|
||||
* Carries enough metadata for downstream consumers (audit logging,
|
||||
* subscription activation, accounting) without requiring a re-read
|
||||
* of the payment aggregate.
|
||||
*/
|
||||
export class BankTransferConfirmedEvent implements DomainEvent {
|
||||
readonly eventName = 'payment.bank_transfer_confirmed';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
public readonly type: PaymentType,
|
||||
public readonly amountVND: bigint,
|
||||
public readonly confirmedBy: string,
|
||||
public readonly bankReference: string | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ConfirmBankTransferCommand } from '../../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
||||
import { AdminPaymentsController } from '../admin-payments.controller';
|
||||
|
||||
describe('AdminPaymentsController', () => {
|
||||
let controller: AdminPaymentsController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockAdmin = { sub: 'admin-1', phone: '0901234567', role: 'ADMIN' } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
controller = new AdminPaymentsController(mockCommandBus as any);
|
||||
});
|
||||
|
||||
describe('POST /admin/payments/:id/confirm-transfer', () => {
|
||||
it('dispatches ConfirmBankTransferCommand with admin sub + bankReference', async () => {
|
||||
const expected = {
|
||||
paymentId: 'pay-1',
|
||||
status: 'COMPLETED',
|
||||
confirmedBy: 'admin-1',
|
||||
};
|
||||
mockCommandBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.confirmBankTransfer(
|
||||
'pay-1',
|
||||
{ bankReference: 'FT123456' } as any,
|
||||
mockAdmin,
|
||||
);
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||
expect.any(ConfirmBankTransferCommand),
|
||||
);
|
||||
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
|
||||
expect(cmd.paymentId).toBe('pay-1');
|
||||
expect(cmd.confirmedBy).toBe('admin-1');
|
||||
expect(cmd.bankReference).toBe('FT123456');
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('supports omitted bankReference', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({
|
||||
paymentId: 'pay-2',
|
||||
status: 'COMPLETED',
|
||||
confirmedBy: 'admin-1',
|
||||
});
|
||||
|
||||
await controller.confirmBankTransfer('pay-2', {} as any, mockAdmin);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0]![0] as ConfirmBankTransferCommand;
|
||||
expect(cmd.paymentId).toBe('pay-2');
|
||||
expect(cmd.confirmedBy).toBe('admin-1');
|
||||
expect(cmd.bankReference).toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates errors from the command bus', async () => {
|
||||
mockCommandBus.execute.mockRejectedValue(new Error('validation failed'));
|
||||
|
||||
await expect(
|
||||
controller.confirmBankTransfer('pay-3', {} as any, mockAdmin),
|
||||
).rejects.toThrow('validation failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { type CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload, Roles, RolesGuard } from '@modules/auth';
|
||||
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
|
||||
import { type ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
||||
import { type ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
|
||||
|
||||
/**
|
||||
* Admin-only controller for manual payment reconciliation.
|
||||
*
|
||||
* Separated from the user-facing `PaymentsController` so the audit/RBAC
|
||||
* surface is clearly scoped under `/admin/payments/*`.
|
||||
*/
|
||||
@ApiTags('admin-payments')
|
||||
@ApiBearerAuth('JWT')
|
||||
@Controller('admin/payments')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
export class AdminPaymentsController {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
@Post(':id/confirm-transfer')
|
||||
@ApiOperation({
|
||||
summary: 'Confirm a VN bank transfer payment (admin only)',
|
||||
description:
|
||||
'Marks a pending/processing BANK_TRANSFER payment as COMPLETED. ' +
|
||||
'Emits payment.completed + payment.bank_transfer_confirmed events ' +
|
||||
'so audit logs and subscription activation fire automatically.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Payment id to confirm' })
|
||||
@ApiResponse({ status: 201, description: 'Bank transfer confirmed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Payment is not a bank transfer or invalid status' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
||||
@ApiResponse({ status: 404, description: 'Payment not found' })
|
||||
async confirmBankTransfer(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ConfirmBankTransferDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ConfirmBankTransferResult> {
|
||||
return this.commandBus.execute(
|
||||
new ConfirmBankTransferCommand(id, user.sub, dto.bankReference),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
|
||||
export class CreateProjectCommand {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly slug: string,
|
||||
public readonly developer: string,
|
||||
public readonly developerLogo: string | null,
|
||||
public readonly totalUnits: number,
|
||||
public readonly status: ProjectDevelopmentStatus,
|
||||
public readonly latitude: number,
|
||||
public readonly longitude: number,
|
||||
public readonly address: string,
|
||||
public readonly ward: string,
|
||||
public readonly district: string,
|
||||
public readonly city: string,
|
||||
public readonly description: string | null,
|
||||
public readonly amenities: Record<string, unknown> | null,
|
||||
public readonly masterPlanUrl: string | null,
|
||||
public readonly minPrice: bigint | null,
|
||||
public readonly maxPrice: bigint | null,
|
||||
public readonly pricePerM2Range: Record<string, unknown> | null,
|
||||
public readonly totalArea: number | null,
|
||||
public readonly buildingCount: number | null,
|
||||
public readonly floorCount: number | null,
|
||||
public readonly unitTypes: Record<string, unknown> | null,
|
||||
public readonly tags: string[],
|
||||
public readonly startDate: Date | null,
|
||||
public readonly completionDate: Date | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity } from '../../../domain/entities/project-development.entity';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../domain/repositories/project-development.repository';
|
||||
import { CreateProjectCommand } from './create-project.command';
|
||||
|
||||
@CommandHandler(CreateProjectCommand)
|
||||
export class CreateProjectHandler implements ICommandHandler<CreateProjectCommand> {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly repo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateProjectCommand): Promise<{ id: string; slug: string }> {
|
||||
const existing = await this.repo.findBySlug(cmd.slug);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Dự án với slug "${cmd.slug}" đã tồn tại`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const entity = new ProjectDevelopmentEntity(
|
||||
createId(),
|
||||
{
|
||||
name: cmd.name,
|
||||
slug: cmd.slug,
|
||||
developer: cmd.developer,
|
||||
developerLogo: cmd.developerLogo,
|
||||
totalUnits: cmd.totalUnits,
|
||||
completedUnits: 0,
|
||||
status: cmd.status,
|
||||
startDate: cmd.startDate,
|
||||
completionDate: cmd.completionDate,
|
||||
description: cmd.description,
|
||||
amenities: cmd.amenities,
|
||||
masterPlanUrl: cmd.masterPlanUrl,
|
||||
latitude: cmd.latitude,
|
||||
longitude: cmd.longitude,
|
||||
address: cmd.address,
|
||||
ward: cmd.ward,
|
||||
district: cmd.district,
|
||||
city: cmd.city,
|
||||
minPrice: cmd.minPrice,
|
||||
maxPrice: cmd.maxPrice,
|
||||
pricePerM2Range: cmd.pricePerM2Range,
|
||||
totalArea: cmd.totalArea,
|
||||
buildingCount: cmd.buildingCount,
|
||||
floorCount: cmd.floorCount,
|
||||
unitTypes: cmd.unitTypes,
|
||||
media: null,
|
||||
documents: null,
|
||||
tags: cmd.tags,
|
||||
isVerified: false,
|
||||
},
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
await this.repo.save(entity);
|
||||
return { id: entity.id, slug: entity.slug };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateProjectCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly name?: string,
|
||||
public readonly developer?: string,
|
||||
public readonly developerLogo?: string | null,
|
||||
public readonly totalUnits?: number,
|
||||
public readonly completedUnits?: number,
|
||||
public readonly status?: ProjectDevelopmentStatus,
|
||||
public readonly description?: string | null,
|
||||
public readonly amenities?: Record<string, unknown> | null,
|
||||
public readonly masterPlanUrl?: string | null,
|
||||
public readonly minPrice?: bigint | null,
|
||||
public readonly maxPrice?: bigint | null,
|
||||
public readonly pricePerM2Range?: Record<string, unknown> | null,
|
||||
public readonly totalArea?: number | null,
|
||||
public readonly buildingCount?: number | null,
|
||||
public readonly floorCount?: number | null,
|
||||
public readonly unitTypes?: Record<string, unknown> | null,
|
||||
public readonly media?: Record<string, unknown>[] | null,
|
||||
public readonly documents?: Record<string, unknown>[] | null,
|
||||
public readonly tags?: string[],
|
||||
public readonly isVerified?: boolean,
|
||||
public readonly startDate?: Date | null,
|
||||
public readonly completionDate?: Date | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../domain/repositories/project-development.repository';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
|
||||
@CommandHandler(UpdateProjectCommand)
|
||||
export class UpdateProjectHandler implements ICommandHandler<UpdateProjectCommand> {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly repo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
async execute(cmd: UpdateProjectCommand): Promise<{ id: string }> {
|
||||
const entity = await this.repo.findById(cmd.id);
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Dự án', cmd.id);
|
||||
}
|
||||
|
||||
entity.updateDetails({
|
||||
...(cmd.name !== undefined && { name: cmd.name }),
|
||||
...(cmd.developer !== undefined && { developer: cmd.developer }),
|
||||
...(cmd.developerLogo !== undefined && { developerLogo: cmd.developerLogo }),
|
||||
...(cmd.totalUnits !== undefined && { totalUnits: cmd.totalUnits }),
|
||||
...(cmd.completedUnits !== undefined && { completedUnits: cmd.completedUnits }),
|
||||
...(cmd.status !== undefined && { status: cmd.status }),
|
||||
...(cmd.description !== undefined && { description: cmd.description }),
|
||||
...(cmd.amenities !== undefined && { amenities: cmd.amenities }),
|
||||
...(cmd.masterPlanUrl !== undefined && { masterPlanUrl: cmd.masterPlanUrl }),
|
||||
...(cmd.minPrice !== undefined && { minPrice: cmd.minPrice }),
|
||||
...(cmd.maxPrice !== undefined && { maxPrice: cmd.maxPrice }),
|
||||
...(cmd.pricePerM2Range !== undefined && { pricePerM2Range: cmd.pricePerM2Range }),
|
||||
...(cmd.totalArea !== undefined && { totalArea: cmd.totalArea }),
|
||||
...(cmd.buildingCount !== undefined && { buildingCount: cmd.buildingCount }),
|
||||
...(cmd.floorCount !== undefined && { floorCount: cmd.floorCount }),
|
||||
...(cmd.unitTypes !== undefined && { unitTypes: cmd.unitTypes }),
|
||||
...(cmd.media !== undefined && { media: cmd.media }),
|
||||
...(cmd.documents !== undefined && { documents: cmd.documents }),
|
||||
...(cmd.tags !== undefined && { tags: cmd.tags }),
|
||||
...(cmd.isVerified !== undefined && { isVerified: cmd.isVerified }),
|
||||
...(cmd.startDate !== undefined && { startDate: cmd.startDate }),
|
||||
...(cmd.completionDate !== undefined && { completionDate: cmd.completionDate }),
|
||||
});
|
||||
|
||||
await this.repo.update(entity);
|
||||
return { id: entity.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
type ProjectDetailData,
|
||||
} from '../../../domain/repositories/project-development.repository';
|
||||
import { GetProjectQuery } from './get-project.query';
|
||||
|
||||
@QueryHandler(GetProjectQuery)
|
||||
export class GetProjectHandler implements IQueryHandler<GetProjectQuery> {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly repo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetProjectQuery): Promise<ProjectDetailData | null> {
|
||||
// Try slug first, then ID
|
||||
const bySlug = await this.repo.findDetailBySlug(query.slugOrId);
|
||||
if (bySlug) return bySlug;
|
||||
return this.repo.findDetailById(query.slugOrId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetProjectQuery {
|
||||
constructor(public readonly slugOrId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
type PaginatedResult,
|
||||
type ProjectListItem,
|
||||
} from '../../../domain/repositories/project-development.repository';
|
||||
import { ListProjectsQuery } from './list-projects.query';
|
||||
|
||||
@QueryHandler(ListProjectsQuery)
|
||||
export class ListProjectsHandler implements IQueryHandler<ListProjectsQuery> {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly repo: IProjectRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListProjectsQuery): Promise<PaginatedResult<ProjectListItem>> {
|
||||
return this.repo.search({
|
||||
query: query.query,
|
||||
status: query.status,
|
||||
city: query.city,
|
||||
district: query.district,
|
||||
developer: query.developer,
|
||||
isVerified: query.isVerified,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
|
||||
export class ListProjectsQuery {
|
||||
constructor(
|
||||
public readonly query: string | undefined,
|
||||
public readonly status: ProjectDevelopmentStatus | undefined,
|
||||
public readonly city: string | undefined,
|
||||
public readonly district: string | undefined,
|
||||
public readonly developer: string | undefined,
|
||||
public readonly isVerified: boolean | undefined,
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { type ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { AggregateRoot } from '@modules/shared';
|
||||
|
||||
export interface ProjectDevelopmentProps {
|
||||
name: string;
|
||||
slug: string;
|
||||
developer: string;
|
||||
developerLogo: string | null;
|
||||
totalUnits: number;
|
||||
completedUnits: number;
|
||||
status: ProjectDevelopmentStatus;
|
||||
startDate: Date | null;
|
||||
completionDate: Date | null;
|
||||
description: string | null;
|
||||
amenities: Record<string, unknown> | null;
|
||||
masterPlanUrl: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
minPrice: bigint | null;
|
||||
maxPrice: bigint | null;
|
||||
pricePerM2Range: Record<string, unknown> | null;
|
||||
totalArea: number | null;
|
||||
buildingCount: number | null;
|
||||
floorCount: number | null;
|
||||
unitTypes: Record<string, unknown> | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
documents: Record<string, unknown>[] | null;
|
||||
tags: string[];
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
export class ProjectDevelopmentEntity extends AggregateRoot<string> {
|
||||
private _name: string;
|
||||
private _slug: string;
|
||||
private _developer: string;
|
||||
private _developerLogo: string | null;
|
||||
private _totalUnits: number;
|
||||
private _completedUnits: number;
|
||||
private _status: ProjectDevelopmentStatus;
|
||||
private _startDate: Date | null;
|
||||
private _completionDate: Date | null;
|
||||
private _description: string | null;
|
||||
private _amenities: Record<string, unknown> | null;
|
||||
private _masterPlanUrl: string | null;
|
||||
private _latitude: number;
|
||||
private _longitude: number;
|
||||
private _address: string;
|
||||
private _ward: string;
|
||||
private _district: string;
|
||||
private _city: string;
|
||||
private _minPrice: bigint | null;
|
||||
private _maxPrice: bigint | null;
|
||||
private _pricePerM2Range: Record<string, unknown> | null;
|
||||
private _totalArea: number | null;
|
||||
private _buildingCount: number | null;
|
||||
private _floorCount: number | null;
|
||||
private _unitTypes: Record<string, unknown> | null;
|
||||
private _media: Record<string, unknown>[] | null;
|
||||
private _documents: Record<string, unknown>[] | null;
|
||||
private _tags: string[];
|
||||
private _isVerified: boolean;
|
||||
|
||||
constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._name = props.name;
|
||||
this._slug = props.slug;
|
||||
this._developer = props.developer;
|
||||
this._developerLogo = props.developerLogo;
|
||||
this._totalUnits = props.totalUnits;
|
||||
this._completedUnits = props.completedUnits;
|
||||
this._status = props.status;
|
||||
this._startDate = props.startDate;
|
||||
this._completionDate = props.completionDate;
|
||||
this._description = props.description;
|
||||
this._amenities = props.amenities;
|
||||
this._masterPlanUrl = props.masterPlanUrl;
|
||||
this._latitude = props.latitude;
|
||||
this._longitude = props.longitude;
|
||||
this._address = props.address;
|
||||
this._ward = props.ward;
|
||||
this._district = props.district;
|
||||
this._city = props.city;
|
||||
this._minPrice = props.minPrice;
|
||||
this._maxPrice = props.maxPrice;
|
||||
this._pricePerM2Range = props.pricePerM2Range;
|
||||
this._totalArea = props.totalArea;
|
||||
this._buildingCount = props.buildingCount;
|
||||
this._floorCount = props.floorCount;
|
||||
this._unitTypes = props.unitTypes;
|
||||
this._media = props.media;
|
||||
this._documents = props.documents;
|
||||
this._tags = props.tags;
|
||||
this._isVerified = props.isVerified;
|
||||
}
|
||||
|
||||
get name() { return this._name; }
|
||||
get slug() { return this._slug; }
|
||||
get developer() { return this._developer; }
|
||||
get developerLogo() { return this._developerLogo; }
|
||||
get totalUnits() { return this._totalUnits; }
|
||||
get completedUnits() { return this._completedUnits; }
|
||||
get status() { return this._status; }
|
||||
get startDate() { return this._startDate; }
|
||||
get completionDate() { return this._completionDate; }
|
||||
get description() { return this._description; }
|
||||
get amenities() { return this._amenities; }
|
||||
get masterPlanUrl() { return this._masterPlanUrl; }
|
||||
get latitude() { return this._latitude; }
|
||||
get longitude() { return this._longitude; }
|
||||
get address() { return this._address; }
|
||||
get ward() { return this._ward; }
|
||||
get district() { return this._district; }
|
||||
get city() { return this._city; }
|
||||
get minPrice() { return this._minPrice; }
|
||||
get maxPrice() { return this._maxPrice; }
|
||||
get pricePerM2Range() { return this._pricePerM2Range; }
|
||||
get totalArea() { return this._totalArea; }
|
||||
get buildingCount() { return this._buildingCount; }
|
||||
get floorCount() { return this._floorCount; }
|
||||
get unitTypes() { return this._unitTypes; }
|
||||
get media() { return this._media; }
|
||||
get documents() { return this._documents; }
|
||||
get tags() { return this._tags; }
|
||||
get isVerified() { return this._isVerified; }
|
||||
|
||||
updateDetails(props: Partial<ProjectDevelopmentProps>): void {
|
||||
if (props.name !== undefined) this._name = props.name;
|
||||
if (props.developer !== undefined) this._developer = props.developer;
|
||||
if (props.developerLogo !== undefined) this._developerLogo = props.developerLogo;
|
||||
if (props.totalUnits !== undefined) this._totalUnits = props.totalUnits;
|
||||
if (props.completedUnits !== undefined) this._completedUnits = props.completedUnits;
|
||||
if (props.status !== undefined) this._status = props.status;
|
||||
if (props.startDate !== undefined) this._startDate = props.startDate;
|
||||
if (props.completionDate !== undefined) this._completionDate = props.completionDate;
|
||||
if (props.description !== undefined) this._description = props.description;
|
||||
if (props.amenities !== undefined) this._amenities = props.amenities;
|
||||
if (props.masterPlanUrl !== undefined) this._masterPlanUrl = props.masterPlanUrl;
|
||||
if (props.minPrice !== undefined) this._minPrice = props.minPrice;
|
||||
if (props.maxPrice !== undefined) this._maxPrice = props.maxPrice;
|
||||
if (props.pricePerM2Range !== undefined) this._pricePerM2Range = props.pricePerM2Range;
|
||||
if (props.totalArea !== undefined) this._totalArea = props.totalArea;
|
||||
if (props.buildingCount !== undefined) this._buildingCount = props.buildingCount;
|
||||
if (props.floorCount !== undefined) this._floorCount = props.floorCount;
|
||||
if (props.unitTypes !== undefined) this._unitTypes = props.unitTypes;
|
||||
if (props.media !== undefined) this._media = props.media;
|
||||
if (props.documents !== undefined) this._documents = props.documents;
|
||||
if (props.tags !== undefined) this._tags = props.tags;
|
||||
if (props.isVerified !== undefined) this._isVerified = props.isVerified;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import type { ProjectDevelopmentEntity } from '../entities/project-development.entity';
|
||||
|
||||
export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
|
||||
|
||||
export interface ProjectSearchParams {
|
||||
query?: string;
|
||||
status?: ProjectDevelopmentStatus;
|
||||
city?: string;
|
||||
district?: string;
|
||||
developer?: string;
|
||||
isVerified?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ProjectListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
developer: string;
|
||||
developerLogo: string | null;
|
||||
status: ProjectDevelopmentStatus;
|
||||
totalUnits: number;
|
||||
completedUnits: number;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
minPrice: bigint | null;
|
||||
maxPrice: bigint | null;
|
||||
totalArea: number | null;
|
||||
tags: string[];
|
||||
isVerified: boolean;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
propertyCount: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ProjectDetailData extends ProjectListItem {
|
||||
startDate: Date | null;
|
||||
completionDate: Date | null;
|
||||
description: string | null;
|
||||
amenities: Record<string, unknown> | null;
|
||||
masterPlanUrl: string | null;
|
||||
pricePerM2Range: Record<string, unknown> | null;
|
||||
buildingCount: number | null;
|
||||
floorCount: number | null;
|
||||
unitTypes: Record<string, unknown> | null;
|
||||
media: Record<string, unknown>[] | null;
|
||||
documents: Record<string, unknown>[] | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IProjectRepository {
|
||||
findById(id: string): Promise<ProjectDevelopmentEntity | null>;
|
||||
findBySlug(slug: string): Promise<ProjectDevelopmentEntity | null>;
|
||||
findDetailBySlug(slug: string): Promise<ProjectDetailData | null>;
|
||||
findDetailById(id: string): Promise<ProjectDetailData | null>;
|
||||
save(entity: ProjectDevelopmentEntity): Promise<void>;
|
||||
update(entity: ProjectDevelopmentEntity): Promise<void>;
|
||||
search(params: ProjectSearchParams): Promise<PaginatedResult<ProjectListItem>>;
|
||||
}
|
||||
7
apps/api/src/modules/projects/index.ts
Normal file
7
apps/api/src/modules/projects/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ProjectsModule } from './projects.module';
|
||||
export { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository';
|
||||
export type {
|
||||
IProjectRepository,
|
||||
ProjectDetailData,
|
||||
ProjectListItem,
|
||||
} from './domain/repositories/project-development.repository';
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity } from '../../domain/entities/project-development.entity';
|
||||
import type {
|
||||
IProjectRepository,
|
||||
ProjectSearchParams,
|
||||
PaginatedResult,
|
||||
ProjectListItem,
|
||||
ProjectDetailData,
|
||||
} from '../../domain/repositories/project-development.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaProjectDevelopmentRepository implements IProjectRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<ProjectDevelopmentEntity | null> {
|
||||
const row = await this.prisma.$queryRaw<RawProject[]>`
|
||||
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||
FROM "ProjectDevelopment" WHERE id = ${id} LIMIT 1
|
||||
`;
|
||||
return row[0] ? this.toDomain(row[0]) : null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<ProjectDevelopmentEntity | null> {
|
||||
const row = await this.prisma.$queryRaw<RawProject[]>`
|
||||
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
|
||||
FROM "ProjectDevelopment" WHERE slug = ${slug} LIMIT 1
|
||||
`;
|
||||
return row[0] ? this.toDomain(row[0]) : null;
|
||||
}
|
||||
|
||||
async findDetailBySlug(slug: string): Promise<ProjectDetailData | null> {
|
||||
const rows = await this.prisma.$queryRaw<RawProjectDetail[]>`
|
||||
SELECT p.*,
|
||||
ST_Y(p.location::geometry) as lat,
|
||||
ST_X(p.location::geometry) as lng,
|
||||
COUNT(pr.id)::int as "propertyCount"
|
||||
FROM "ProjectDevelopment" p
|
||||
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||
WHERE p.slug = ${slug}
|
||||
GROUP BY p.id
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||
}
|
||||
|
||||
async findDetailById(id: string): Promise<ProjectDetailData | null> {
|
||||
const rows = await this.prisma.$queryRaw<RawProjectDetail[]>`
|
||||
SELECT p.*,
|
||||
ST_Y(p.location::geometry) as lat,
|
||||
ST_X(p.location::geometry) as lng,
|
||||
COUNT(pr.id)::int as "propertyCount"
|
||||
FROM "ProjectDevelopment" p
|
||||
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||
WHERE p.id = ${id}
|
||||
GROUP BY p.id
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0] ? this.toDetail(rows[0]) : null;
|
||||
}
|
||||
|
||||
async save(entity: ProjectDevelopmentEntity): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "ProjectDevelopment" (
|
||||
id, name, slug, developer, "developerLogo", "totalUnits", "completedUnits",
|
||||
status, "startDate", "completionDate", description, amenities, "masterPlanUrl",
|
||||
location, address, ward, district, city,
|
||||
"minPrice", "maxPrice", "pricePerM2Range", "totalArea",
|
||||
"buildingCount", "floorCount", "unitTypes", media, documents,
|
||||
tags, "isVerified", "createdAt", "updatedAt"
|
||||
) VALUES (
|
||||
${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer},
|
||||
${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits},
|
||||
${entity.status}::"ProjectDevelopmentStatus",
|
||||
${entity.startDate}, ${entity.completionDate},
|
||||
${entity.description},
|
||||
${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
|
||||
${entity.masterPlanUrl},
|
||||
ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326),
|
||||
${entity.address}, ${entity.ward}, ${entity.district}, ${entity.city},
|
||||
${entity.minPrice}, ${entity.maxPrice},
|
||||
${entity.pricePerM2Range ? JSON.stringify(entity.pricePerM2Range) : null}::jsonb,
|
||||
${entity.totalArea}, ${entity.buildingCount}, ${entity.floorCount},
|
||||
${entity.unitTypes ? JSON.stringify(entity.unitTypes) : null}::jsonb,
|
||||
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
|
||||
${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
|
||||
${entity.tags}::text[],
|
||||
${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
async update(entity: ProjectDevelopmentEntity): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "ProjectDevelopment" SET
|
||||
name = ${entity.name}, developer = ${entity.developer},
|
||||
"developerLogo" = ${entity.developerLogo},
|
||||
"totalUnits" = ${entity.totalUnits}, "completedUnits" = ${entity.completedUnits},
|
||||
status = ${entity.status}::"ProjectDevelopmentStatus",
|
||||
"startDate" = ${entity.startDate}, "completionDate" = ${entity.completionDate},
|
||||
description = ${entity.description},
|
||||
amenities = ${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb,
|
||||
"masterPlanUrl" = ${entity.masterPlanUrl},
|
||||
"minPrice" = ${entity.minPrice}, "maxPrice" = ${entity.maxPrice},
|
||||
"pricePerM2Range" = ${entity.pricePerM2Range ? JSON.stringify(entity.pricePerM2Range) : null}::jsonb,
|
||||
"totalArea" = ${entity.totalArea},
|
||||
"buildingCount" = ${entity.buildingCount}, "floorCount" = ${entity.floorCount},
|
||||
"unitTypes" = ${entity.unitTypes ? JSON.stringify(entity.unitTypes) : null}::jsonb,
|
||||
media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
|
||||
documents = ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
|
||||
tags = ${entity.tags}::text[],
|
||||
"isVerified" = ${entity.isVerified},
|
||||
"updatedAt" = ${entity.updatedAt}
|
||||
WHERE id = ${entity.id}
|
||||
`;
|
||||
}
|
||||
|
||||
async search(params: ProjectSearchParams): Promise<PaginatedResult<ProjectListItem>> {
|
||||
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.status) {
|
||||
conditions.push(`status = $${paramIndex++}::"ProjectDevelopmentStatus"`);
|
||||
values.push(params.status);
|
||||
}
|
||||
if (params.city) {
|
||||
conditions.push(`city = $${paramIndex++}`);
|
||||
values.push(params.city);
|
||||
}
|
||||
if (params.district) {
|
||||
conditions.push(`district = $${paramIndex++}`);
|
||||
values.push(params.district);
|
||||
}
|
||||
if (params.developer) {
|
||||
conditions.push(`developer ILIKE $${paramIndex++}`);
|
||||
values.push(`%${params.developer}%`);
|
||||
}
|
||||
if (params.isVerified !== undefined) {
|
||||
conditions.push(`"isVerified" = $${paramIndex++}`);
|
||||
values.push(params.isVerified);
|
||||
}
|
||||
if (params.query) {
|
||||
conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city 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 "ProjectDevelopment" WHERE ${where}`,
|
||||
...values,
|
||||
);
|
||||
const total = Number(countResult[0].count);
|
||||
|
||||
const rows = await this.prisma.$queryRawUnsafe<RawProjectDetail[]>(
|
||||
`SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||
COUNT(pr.id)::int as "propertyCount"
|
||||
FROM "ProjectDevelopment" p
|
||||
LEFT JOIN "Property" pr ON pr."projectId" = p.id
|
||||
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)}
|
||||
GROUP BY p.id
|
||||
ORDER BY p."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),
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(row: RawProject): ProjectDevelopmentEntity {
|
||||
return new ProjectDevelopmentEntity(
|
||||
row.id,
|
||||
{
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
developer: row.developer,
|
||||
developerLogo: row.developerLogo,
|
||||
totalUnits: row.totalUnits,
|
||||
completedUnits: row.completedUnits,
|
||||
status: row.status,
|
||||
startDate: row.startDate,
|
||||
completionDate: row.completionDate,
|
||||
description: row.description,
|
||||
amenities: row.amenities as Record<string, unknown> | null,
|
||||
masterPlanUrl: row.masterPlanUrl,
|
||||
latitude: Number(row.lat),
|
||||
longitude: Number(row.lng),
|
||||
address: row.address,
|
||||
ward: row.ward,
|
||||
district: row.district,
|
||||
city: row.city,
|
||||
minPrice: row.minPrice,
|
||||
maxPrice: row.maxPrice,
|
||||
pricePerM2Range: row.pricePerM2Range as Record<string, unknown> | null,
|
||||
totalArea: row.totalArea,
|
||||
buildingCount: row.buildingCount,
|
||||
floorCount: row.floorCount,
|
||||
unitTypes: row.unitTypes as Record<string, unknown> | null,
|
||||
media: row.media as Record<string, unknown>[] | null,
|
||||
documents: row.documents as Record<string, unknown>[] | null,
|
||||
tags: row.tags ?? [],
|
||||
isVerified: row.isVerified,
|
||||
},
|
||||
row.createdAt,
|
||||
row.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private toListItem(row: RawProjectDetail): ProjectListItem {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
developer: row.developer,
|
||||
developerLogo: row.developerLogo,
|
||||
status: row.status,
|
||||
totalUnits: row.totalUnits,
|
||||
completedUnits: row.completedUnits,
|
||||
address: row.address,
|
||||
ward: row.ward,
|
||||
district: row.district,
|
||||
city: row.city,
|
||||
minPrice: row.minPrice,
|
||||
maxPrice: row.maxPrice,
|
||||
totalArea: row.totalArea,
|
||||
tags: row.tags ?? [],
|
||||
isVerified: row.isVerified,
|
||||
latitude: Number(row.lat),
|
||||
longitude: Number(row.lng),
|
||||
propertyCount: row.propertyCount ?? 0,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toDetail(row: RawProjectDetail): ProjectDetailData {
|
||||
return {
|
||||
...this.toListItem(row),
|
||||
startDate: row.startDate,
|
||||
completionDate: row.completionDate,
|
||||
description: row.description,
|
||||
amenities: row.amenities as Record<string, unknown> | null,
|
||||
masterPlanUrl: row.masterPlanUrl,
|
||||
pricePerM2Range: row.pricePerM2Range as Record<string, unknown> | null,
|
||||
buildingCount: row.buildingCount,
|
||||
floorCount: row.floorCount,
|
||||
unitTypes: row.unitTypes as Record<string, unknown> | null,
|
||||
media: row.media as Record<string, unknown>[] | null,
|
||||
documents: row.documents as Record<string, unknown>[] | null,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RawProject {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
developer: string;
|
||||
developerLogo: string | null;
|
||||
totalUnits: number;
|
||||
completedUnits: number;
|
||||
status: 'PLANNING' | 'UNDER_CONSTRUCTION' | 'COMPLETED' | 'HANDOVER';
|
||||
startDate: Date | null;
|
||||
completionDate: Date | null;
|
||||
description: string | null;
|
||||
amenities: Prisma.JsonValue;
|
||||
masterPlanUrl: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
minPrice: bigint | null;
|
||||
maxPrice: bigint | null;
|
||||
pricePerM2Range: Prisma.JsonValue;
|
||||
totalArea: number | null;
|
||||
buildingCount: number | null;
|
||||
floorCount: number | null;
|
||||
unitTypes: Prisma.JsonValue;
|
||||
media: Prisma.JsonValue;
|
||||
documents: Prisma.JsonValue;
|
||||
tags: string[] | null;
|
||||
isVerified: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface RawProjectDetail extends RawProject {
|
||||
propertyCount: number;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { CreateProjectCommand } from '../../application/commands/create-project/create-project.command';
|
||||
import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command';
|
||||
import { GetProjectQuery } from '../../application/queries/get-project/get-project.query';
|
||||
import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query';
|
||||
import { type CreateProjectDto } from '../dto/create-project.dto';
|
||||
import { type SearchProjectsDto } from '../dto/search-projects.dto';
|
||||
import { type UpdateProjectDto } from '../dto/update-project.dto';
|
||||
|
||||
@ApiTags('projects')
|
||||
@Controller('projects')
|
||||
export class ProjectsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
// ── Public endpoints ──────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Danh sách dự án', description: 'Tìm kiếm và lọc dự án bất động sản' })
|
||||
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
||||
@Get()
|
||||
async listProjects(@Query() dto: SearchProjectsDto) {
|
||||
return this.queryBus.execute(
|
||||
new ListProjectsQuery(
|
||||
dto.q,
|
||||
dto.status,
|
||||
dto.city,
|
||||
dto.district,
|
||||
dto.developer,
|
||||
dto.isVerified,
|
||||
dto.page ?? 1,
|
||||
dto.limit ?? 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Chi tiết dự án', description: 'Xem chi tiết dự án theo slug hoặc ID' })
|
||||
@ApiResponse({ status: 200, description: 'Thông tin chi tiết dự án' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
|
||||
@Get(':slugOrId')
|
||||
async getProject(@Param('slugOrId') slugOrId: string) {
|
||||
const result = await this.queryBus.execute(new GetProjectQuery(slugOrId));
|
||||
if (!result) {
|
||||
throw new NotFoundException('Dự án', slugOrId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Admin endpoints ───────────────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Tạo dự án (admin)', description: 'Tạo mới dự án bất động sản' })
|
||||
@ApiResponse({ status: 201, description: 'Dự án đã tạo' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Post()
|
||||
async createProject(@Body() dto: CreateProjectDto) {
|
||||
return this.commandBus.execute(
|
||||
new CreateProjectCommand(
|
||||
dto.name,
|
||||
dto.slug,
|
||||
dto.developer,
|
||||
dto.developerLogo ?? null,
|
||||
dto.totalUnits,
|
||||
dto.status,
|
||||
dto.latitude,
|
||||
dto.longitude,
|
||||
dto.address,
|
||||
dto.ward,
|
||||
dto.district,
|
||||
dto.city,
|
||||
dto.description ?? null,
|
||||
dto.amenities ?? null,
|
||||
dto.masterPlanUrl ?? null,
|
||||
dto.minPrice ? BigInt(dto.minPrice) : null,
|
||||
dto.maxPrice ? BigInt(dto.maxPrice) : null,
|
||||
dto.pricePerM2Range ?? null,
|
||||
dto.totalArea ?? null,
|
||||
dto.buildingCount ?? null,
|
||||
dto.floorCount ?? null,
|
||||
dto.unitTypes ?? null,
|
||||
dto.tags ?? [],
|
||||
dto.startDate ? new Date(dto.startDate) : null,
|
||||
dto.completionDate ? new Date(dto.completionDate) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Cập nhật dự án (admin)', description: 'Cập nhật thông tin dự án' })
|
||||
@ApiResponse({ status: 200, description: 'Dự án đã cập nhật' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Patch(':id')
|
||||
async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
||||
return this.commandBus.execute(
|
||||
new UpdateProjectCommand(
|
||||
id,
|
||||
dto.name,
|
||||
dto.developer,
|
||||
dto.developerLogo,
|
||||
dto.totalUnits,
|
||||
dto.completedUnits,
|
||||
dto.status,
|
||||
dto.description,
|
||||
dto.amenities,
|
||||
dto.masterPlanUrl,
|
||||
dto.minPrice !== undefined ? (dto.minPrice ? BigInt(dto.minPrice) : null) : undefined,
|
||||
dto.maxPrice !== undefined ? (dto.maxPrice ? BigInt(dto.maxPrice) : null) : undefined,
|
||||
dto.pricePerM2Range,
|
||||
dto.totalArea,
|
||||
dto.buildingCount,
|
||||
dto.floorCount,
|
||||
dto.unitTypes,
|
||||
dto.media,
|
||||
dto.documents,
|
||||
dto.tags,
|
||||
dto.isVerified,
|
||||
dto.startDate !== undefined ? (dto.startDate ? new Date(dto.startDate) : null) : undefined,
|
||||
dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@ApiProperty({ example: 'Vinhomes Grand Park', description: 'Tên dự án' })
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'vinhomes-grand-park', description: 'URL slug (unique)' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({ example: 'Vingroup' })
|
||||
@IsString()
|
||||
developer!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
developerLogo?: string;
|
||||
|
||||
@ApiProperty({ example: 10000, description: 'Tổng số căn hộ/đơn vị' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
totalUnits!: number;
|
||||
|
||||
@ApiProperty({ enum: ProjectDevelopmentStatus, example: 'UNDER_CONSTRUCTION' })
|
||||
@IsEnum(ProjectDevelopmentStatus)
|
||||
status!: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiProperty({ example: 10.8231, description: 'Latitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.8368, description: 'Longitude' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ example: 'Phường Long Thạnh Mỹ, TP. Thủ Đức' })
|
||||
@IsString()
|
||||
address!: string;
|
||||
|
||||
@ApiProperty({ example: 'Long Thạnh Mỹ' })
|
||||
@IsString()
|
||||
ward!: string;
|
||||
|
||||
@ApiProperty({ example: 'Thủ Đức' })
|
||||
@IsString()
|
||||
district!: string;
|
||||
|
||||
@ApiProperty({ example: 'Hồ Chí Minh' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mô tả dự án' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Tiện ích dự án (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
amenities?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/masterplan.jpg' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
masterPlanUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '3000000000', description: 'Giá thấp nhất (VND)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '15000000000', description: 'Giá cao nhất (VND)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Giá/m² range (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
pricePerM2Range?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: 271, description: 'Tổng diện tích (ha)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
totalArea?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 14 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
buildingCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 35 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
floorCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Loại căn hộ (JSON)' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
unitTypes?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ example: ['cao-cap', 'can-ho'], description: 'Tags' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: '2020-06-01', description: 'Ngày khởi công' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2025-12-31', description: 'Ngày dự kiến hoàn thành' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
completionDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsString, IsEnum, IsOptional, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
||||
|
||||
export class SearchProjectsDto {
|
||||
@ApiPropertyOptional({ description: 'Tìm kiếm theo tên, chủ đầu tư, quận, thành phố' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectDevelopmentStatus)
|
||||
status?: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Hồ Chí Minh' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Thủ Đức' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Vingroup' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
developer?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
isVerified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ProjectDevelopmentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
Min,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() developer?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() developerLogo?: string | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(1)
|
||||
totalUnits?: number;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||
completedUnits?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProjectDevelopmentStatus })
|
||||
@IsOptional() @IsEnum(ProjectDevelopmentStatus)
|
||||
status?: ProjectDevelopmentStatus;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() amenities?: Record<string, unknown> | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() masterPlanUrl?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() minPrice?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() maxPrice?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() pricePerM2Range?: Record<string, unknown> | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) @Min(0)
|
||||
totalArea?: number | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) buildingCount?: number | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) floorCount?: number | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsObject() unitTypes?: Record<string, unknown> | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() media?: Record<string, unknown>[] | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() documents?: Record<string, unknown>[] | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: string | null;
|
||||
}
|
||||
24
apps/api/src/modules/projects/projects.module.ts
Normal file
24
apps/api/src/modules/projects/projects.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { CreateProjectHandler } from './application/commands/create-project/create-project.handler';
|
||||
import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler';
|
||||
import { GetProjectHandler } from './application/queries/get-project/get-project.handler';
|
||||
import { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler';
|
||||
import { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository';
|
||||
import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository';
|
||||
import { ProjectsController } from './presentation/controllers/projects.controller';
|
||||
|
||||
const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler];
|
||||
const QueryHandlers = [GetProjectHandler, ListProjectsHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [ProjectsController],
|
||||
providers: [
|
||||
{ provide: PROJECT_REPOSITORY, useClass: PrismaProjectDevelopmentRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [PROJECT_REPOSITORY],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { PuppeteerPdfGeneratorService } from '../services/pdf-generator.service';
|
||||
|
||||
const { mockPdf, mockSetContent, mockNewPage, mockClose } = vi.hoisted(() => {
|
||||
const mockPdf = vi.fn();
|
||||
const mockSetContent = vi.fn();
|
||||
const mockNewPage = vi.fn().mockResolvedValue({
|
||||
setContent: mockSetContent,
|
||||
pdf: mockPdf,
|
||||
});
|
||||
const mockClose = vi.fn();
|
||||
return { mockPdf, mockSetContent, mockNewPage, mockClose };
|
||||
});
|
||||
|
||||
vi.mock('puppeteer', () => ({
|
||||
default: {
|
||||
launch: vi.fn().mockResolvedValue({
|
||||
newPage: mockNewPage,
|
||||
close: mockClose,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('PuppeteerPdfGeneratorService', () => {
|
||||
let service: PuppeteerPdfGeneratorService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PuppeteerPdfGeneratorService();
|
||||
});
|
||||
|
||||
const buildContent = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
reportType: 'INDUSTRIAL_LOCATION',
|
||||
province: 'Bình Dương',
|
||||
generatedAt: '2026-04-01T00:00:00.000Z',
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: 'Báo cáo tổng quan thị trường KCN Bình Dương.',
|
||||
},
|
||||
economic_indicators: {
|
||||
title: 'Chỉ số kinh tế',
|
||||
data: {
|
||||
gdp: [
|
||||
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||
],
|
||||
},
|
||||
charts: {
|
||||
gdp_trend: [
|
||||
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||
],
|
||||
},
|
||||
},
|
||||
infrastructure: {
|
||||
title: 'Hạ tầng',
|
||||
projects: [
|
||||
{ name: 'KCN VSIP III', category: 'industrial_park', status: 'under_construction', investmentVND: 5000000000000 },
|
||||
],
|
||||
summary: {
|
||||
total: 1,
|
||||
byCategory: { industrial_park: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('generates a PDF and returns the file path', async () => {
|
||||
const pdfBuffer = Buffer.from('fake-pdf-content');
|
||||
mockPdf.mockResolvedValue(pdfBuffer);
|
||||
|
||||
const result = await service.generatePdf('report-123', buildContent());
|
||||
|
||||
expect(result).toMatch(/goodgo-report-report-123-\d+\.pdf$/);
|
||||
expect(mockNewPage).toHaveBeenCalledOnce();
|
||||
expect(mockSetContent).toHaveBeenCalledOnce();
|
||||
expect(mockPdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
}),
|
||||
);
|
||||
expect(mockClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sets page content with waitUntil networkidle0', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-456', buildContent());
|
||||
|
||||
expect(mockSetContent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ waitUntil: 'networkidle0' },
|
||||
);
|
||||
});
|
||||
|
||||
it('includes cover page with title, type label, and date', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-789', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Bình Dương');
|
||||
expect(html).toContain('Vị trí khu công nghiệp');
|
||||
expect(html).toContain('class="cover"');
|
||||
expect(html).toContain('GoodGo');
|
||||
});
|
||||
|
||||
it('includes table of contents', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-toc', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Mục lục');
|
||||
expect(html).toContain('class="toc"');
|
||||
expect(html).toContain('Tóm tắt');
|
||||
expect(html).toContain('Chỉ số kinh tế');
|
||||
expect(html).toContain('Hạ tầng');
|
||||
});
|
||||
|
||||
it('renders SVG charts from chart data', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-charts', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).toContain('chart-container');
|
||||
expect(html).toContain('Gdp Trend');
|
||||
});
|
||||
|
||||
it('renders data tables', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-tables', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('data-table');
|
||||
expect(html).toContain('Kỳ');
|
||||
expect(html).toContain('Giá trị');
|
||||
});
|
||||
|
||||
it('renders infrastructure projects table', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-infra', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('KCN VSIP III');
|
||||
expect(html).toContain('Dự án');
|
||||
expect(html).toContain('Vốn đầu tư (VND)');
|
||||
});
|
||||
|
||||
it('includes Be Vietnam Pro font import', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-font', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Be+Vietnam+Pro');
|
||||
expect(html).toContain("font-family: 'Be Vietnam Pro'");
|
||||
});
|
||||
|
||||
it('includes methodology and disclaimer section', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-method', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Phương pháp');
|
||||
expect(html).toContain('Miễn trừ trách nhiệm');
|
||||
expect(html).toContain('research@goodgo.vn');
|
||||
});
|
||||
|
||||
it('includes page number footer', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-footer', buildContent());
|
||||
|
||||
const pdfOptions = mockPdf.mock.calls[0][0];
|
||||
expect(pdfOptions.footerTemplate).toContain('pageNumber');
|
||||
expect(pdfOptions.footerTemplate).toContain('totalPages');
|
||||
expect(pdfOptions.footerTemplate).toContain('GoodGo AI Report');
|
||||
});
|
||||
|
||||
it('escapes HTML in user-provided content', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
const content = buildContent({
|
||||
province: '<script>alert("xss")</script>',
|
||||
});
|
||||
|
||||
await service.generatePdf('report-xss', content);
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('closes browser even if PDF generation fails', async () => {
|
||||
mockPdf.mockRejectedValue(new Error('Render failed'));
|
||||
|
||||
await expect(service.generatePdf('report-fail', buildContent())).rejects.toThrow('Render failed');
|
||||
|
||||
expect(mockClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('handles empty sections gracefully', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
const content = buildContent({ sections: {} });
|
||||
await service.generatePdf('report-empty', content);
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('class="cover"');
|
||||
expect(html).toContain('class="toc"');
|
||||
});
|
||||
|
||||
it('handles missing content fields with defaults', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-defaults', {});
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('class="cover"');
|
||||
});
|
||||
|
||||
it('uses A4 format with 2cm margins', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-margins', buildContent());
|
||||
|
||||
expect(mockPdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { ReportEntity } from '../../domain/entities/report.entity';
|
||||
import { ReportStatus } from '../../domain/enums/report-status.enum';
|
||||
import { ReportType } from '../../domain/enums/report-type.enum';
|
||||
import { type IReportRepository } from '../../domain/repositories/report.repository';
|
||||
import { type IAINarrativeService } from '../../domain/services/ai-narrative.service';
|
||||
import { type IInfrastructureDataService } from '../../domain/services/infrastructure-data.service';
|
||||
import { type IMacroDataService } from '../../domain/services/macro-data.service';
|
||||
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
import { ReportGenerationProcessor } from '../services/report-generation.processor';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn().mockReturnValue(Buffer.from('fake-pdf')),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ReportGenerationProcessor', () => {
|
||||
let processor: ReportGenerationProcessor;
|
||||
let mockReportRepo: { [K in keyof IReportRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMacroData: { [K in keyof IMacroDataService]: ReturnType<typeof vi.fn> };
|
||||
let mockInfraData: { [K in keyof IInfrastructureDataService]: ReturnType<typeof vi.fn> };
|
||||
let mockAINarrative: { [K in keyof IAINarrativeService]: ReturnType<typeof vi.fn> };
|
||||
let mockPdfGenerator: { [K in keyof IPdfGeneratorService]: ReturnType<typeof vi.fn> };
|
||||
let mockPdfStorage: { [K in keyof IPdfStorageService]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createReport = (type: ReportType, params: Record<string, unknown>) =>
|
||||
ReportEntity.createNew('report-1', 'user-1', type, 'Test Report', params);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockReportRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
countByUserInPeriod: vi.fn(),
|
||||
};
|
||||
|
||||
mockMacroData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{ indicator: 'gdp', period: '2025', value: 150000, unit: 'tỷ VND' },
|
||||
{ indicator: 'fdi', period: '2025', value: 5000, unit: 'triệu USD' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockInfraData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{ name: 'KCN VSIP', category: 'industrial_park', status: 'active', investmentVND: BigInt(5000000000000), completionDate: new Date('2024-01-01') },
|
||||
]),
|
||||
};
|
||||
|
||||
mockAINarrative = {
|
||||
generateNarrative: vi.fn().mockResolvedValue('AI-generated analysis text.'),
|
||||
};
|
||||
|
||||
mockPdfGenerator = {
|
||||
generatePdf: vi.fn().mockResolvedValue('/tmp/report.pdf'),
|
||||
};
|
||||
|
||||
mockPdfStorage = {
|
||||
uploadPdf: vi.fn().mockResolvedValue('https://storage.example.com/reports/report-1.pdf'),
|
||||
};
|
||||
|
||||
processor = new ReportGenerationProcessor(
|
||||
mockReportRepo as any,
|
||||
mockMacroData as any,
|
||||
mockInfraData as any,
|
||||
mockAINarrative as any,
|
||||
mockPdfGenerator as any,
|
||||
mockPdfStorage as any,
|
||||
);
|
||||
});
|
||||
|
||||
const makeJob = (reportId: string) => ({ data: { reportId } }) as any;
|
||||
|
||||
it('skips processing when report is not found', async () => {
|
||||
mockReportRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await processor.process(makeJob('nonexistent'));
|
||||
|
||||
expect(mockPdfGenerator.generatePdf).not.toHaveBeenCalled();
|
||||
expect(mockReportRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('INDUSTRIAL_LOCATION report', () => {
|
||||
it('fetches macro data and infra projects, generates narratives, creates PDF', async () => {
|
||||
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Bình Dương' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// Fetches data
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'Bình Dương',
|
||||
expect.arrayContaining(['gdp', 'fdi', 'population']),
|
||||
);
|
||||
expect(mockInfraData.getByProvince).toHaveBeenCalledWith('Bình Dương');
|
||||
|
||||
// Generates AI narratives for 4 sections
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(4);
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||
);
|
||||
|
||||
// Generates and uploads PDF
|
||||
expect(mockPdfGenerator.generatePdf).toHaveBeenCalledOnce();
|
||||
expect(mockPdfStorage.uploadPdf).toHaveBeenCalledOnce();
|
||||
|
||||
// Marks report as ready with pdfUrl
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.pdfUrl).toBe('https://storage.example.com/reports/report-1.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RESIDENTIAL_MARKET report', () => {
|
||||
it('fetches macro data and generates narratives for market sections', async () => {
|
||||
const report = createReport(ReportType.RESIDENTIAL_MARKET, { city: 'TP.HCM', period: 'Q1-2026' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'TP.HCM',
|
||||
expect.arrayContaining(['gdp', 'cpi', 'mortgage_rate']),
|
||||
);
|
||||
|
||||
// 6 narrative sections for residential market
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DISTRICT_ANALYSIS report', () => {
|
||||
it('generates narratives for district sections', async () => {
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 2' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// 5 narrative sections for district analysis
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(5);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic report type', () => {
|
||||
it('generates a single executive summary for unknown report types', async () => {
|
||||
const report = createReport(ReportType.PORTFOLIO, { assets: ['prop-1'] });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledOnce();
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||
);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF generation failure', () => {
|
||||
it('completes report without PDF when PDF generation fails', async () => {
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'Hà Nội', district: 'Hoàn Kiếm' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockPdfGenerator.generatePdf.mockRejectedValue(new Error('Puppeteer crashed'));
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// Report should still be marked ready, but without pdfUrl
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.pdfUrl).toBeNull();
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content generation failure', () => {
|
||||
it('marks report as failed when narrative generation throws', async () => {
|
||||
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Đồng Nai' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockAINarrative.generateNarrative.mockRejectedValue(new Error('AI service unavailable'));
|
||||
|
||||
await expect(processor.process(makeJob('report-1'))).rejects.toThrow('AI service unavailable');
|
||||
|
||||
expect(report.status).toBe(ReportStatus.FAILED);
|
||||
expect(report.errorMsg).toBe('AI service unavailable');
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans up temp PDF file after upload', async () => {
|
||||
const fs = await import('fs');
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 1' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockPdfGenerator.generatePdf.mockResolvedValue('/tmp/goodgo-report-1.pdf');
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type BankTransferConfirmedEvent } from '@modules/payments';
|
||||
import { type LoggerService, type PrismaService } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Handles subscription activation once a bank-transfer payment is confirmed.
|
||||
*
|
||||
* A bank-transfer payment whose `type === 'SUBSCRIPTION'` represents a
|
||||
* pending subscription activation: the user transferred funds offline and
|
||||
* an admin reconciled the payment.
|
||||
*
|
||||
* We extend the user's current subscription period (or mark it active) so
|
||||
* the user regains access immediately after confirmation. Plan selection
|
||||
* happens upstream during payment creation; this listener is the
|
||||
* side-effect hook that flips the subscription status.
|
||||
*
|
||||
* NOTE: Intentionally defensive — if no subscription exists yet the event
|
||||
* is logged and skipped; downstream processes (CS or renewal cron) pick it up.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BankTransferSubscriptionActivationHandler {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('payment.bank_transfer_confirmed', { async: true })
|
||||
async handle(event: BankTransferConfirmedEvent): Promise<void> {
|
||||
if (event.type !== 'SUBSCRIPTION') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await this.prisma.subscription.findFirst({
|
||||
where: { userId: event.userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.warn(
|
||||
`Bank transfer confirmed for userId=${event.userId} but no subscription found to activate — manual CS review required (paymentId=${event.aggregateId})`,
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const baseDate =
|
||||
subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now;
|
||||
|
||||
// Default to 30-day extension; renewal command handles more granular math
|
||||
const nextPeriodEnd = new Date(
|
||||
baseDate.getTime() + 30 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await this.prisma.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
currentPeriodEnd: nextPeriodEnd,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Subscription activated via bank transfer: subscriptionId=${subscription.id}, userId=${event.userId}, paymentId=${event.aggregateId}, periodEnd=${nextPeriodEnd.toISOString()}`,
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
} catch (error) {
|
||||
// Never break the confirm flow — log and let ops replay
|
||||
this.logger.error(
|
||||
`Failed to activate subscription on bank transfer confirmation: paymentId=${event.aggregateId}, userId=${event.userId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GenerateTransferUploadUrlsCommand {
|
||||
constructor(
|
||||
public readonly sellerId: string,
|
||||
public readonly listingId: string | null,
|
||||
public readonly files: { fileName: string; mimeType: string }[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { type CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler as CqrsCommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
MEDIA_STORAGE_SERVICE,
|
||||
type IMediaStorageService,
|
||||
} from '@modules/listings/infrastructure/services/media-storage.service';
|
||||
import { GenerateTransferUploadUrlsCommand } from './generate-transfer-upload-urls.command';
|
||||
|
||||
export interface TransferUploadUrlResult {
|
||||
uploadUrl: string;
|
||||
objectKey: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
@CqrsCommandHandler(GenerateTransferUploadUrlsCommand)
|
||||
export class GenerateTransferUploadUrlsHandler
|
||||
implements ICommandHandler<GenerateTransferUploadUrlsCommand>
|
||||
{
|
||||
private readonly logger = new Logger(GenerateTransferUploadUrlsHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MEDIA_STORAGE_SERVICE)
|
||||
private readonly storage: IMediaStorageService,
|
||||
) {}
|
||||
|
||||
async execute(command: GenerateTransferUploadUrlsCommand): Promise<TransferUploadUrlResult[]> {
|
||||
const folder = command.listingId
|
||||
? `transfer/${command.sellerId}/${command.listingId}`
|
||||
: `transfer/${command.sellerId}/draft`;
|
||||
|
||||
const results: TransferUploadUrlResult[] = [];
|
||||
|
||||
for (const file of command.files.slice(0, 10)) {
|
||||
try {
|
||||
const result = await this.storage.generatePresignedUpload(
|
||||
folder,
|
||||
file.fileName,
|
||||
file.mimeType,
|
||||
600, // 10 min expiry
|
||||
);
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to generate upload URL for ${file.fileName}: ${err instanceof Error ? err.message : 'Unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
|
||||
export { ModerateTransferListingHandler } from './moderate-transfer-listing.handler';
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ModerateTransferListingCommand {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly moderatorId: string,
|
||||
public readonly action: 'approve' | 'reject',
|
||||
public readonly moderationScore?: number,
|
||||
public readonly notes?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { type EventBusService, NotFoundException } from '@modules/shared';
|
||||
import { TransferListingUpdatedEvent } from '../../../domain/events';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
|
||||
|
||||
@CommandHandler(ModerateTransferListingCommand)
|
||||
export class ModerateTransferListingHandler implements ICommandHandler<ModerateTransferListingCommand> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {}
|
||||
|
||||
async execute(cmd: ModerateTransferListingCommand): Promise<{ status: string }> {
|
||||
const entity = await this.repo.findById(cmd.listingId);
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Transfer listing', cmd.listingId);
|
||||
}
|
||||
|
||||
if (cmd.action === 'approve') {
|
||||
entity.approve(cmd.moderationScore, cmd.notes);
|
||||
} else {
|
||||
entity.reject(cmd.moderationScore, cmd.notes);
|
||||
}
|
||||
|
||||
await this.repo.update(entity);
|
||||
|
||||
this.eventBus.publish(new TransferListingUpdatedEvent(cmd.listingId));
|
||||
|
||||
return { status: entity.status };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ListPendingTransfersQuery } from './list-pending-transfers.query';
|
||||
export { ListPendingTransfersHandler } from './list-pending-transfers.handler';
|
||||
@@ -0,0 +1,23 @@
|
||||
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 { ListPendingTransfersQuery } from './list-pending-transfers.query';
|
||||
|
||||
@QueryHandler(ListPendingTransfersQuery)
|
||||
export class ListPendingTransfersHandler implements IQueryHandler<ListPendingTransfersQuery> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: ListPendingTransfersQuery) {
|
||||
return this.repo.search({
|
||||
status: 'PENDING_REVIEW',
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class ListPendingTransfersQuery {
|
||||
constructor(
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
|
||||
class UploadFileSpec {
|
||||
@ApiProperty({ example: 'sofa-front.jpg' })
|
||||
@IsString()
|
||||
fileName!: string;
|
||||
|
||||
@ApiProperty({ example: 'image/jpeg' })
|
||||
@IsMimeType()
|
||||
mimeType!: string;
|
||||
}
|
||||
|
||||
export class GenerateTransferUploadUrlsDto {
|
||||
@ApiProperty({ required: false, description: 'Listing ID (null for draft uploads)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
listingId?: string;
|
||||
|
||||
@ApiProperty({ type: [UploadFileSpec], minItems: 1, maxItems: 10 })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(10)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UploadFileSpec)
|
||||
files!: UploadFileSpec[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ModerateTransferListingDto {
|
||||
@ApiProperty({ enum: ['approve', 'reject'], description: 'Hành động kiểm duyệt' })
|
||||
@IsIn(['approve', 'reject'])
|
||||
action!: 'approve' | 'reject';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Điểm kiểm duyệt (0-100)', minimum: 0, maximum: 100 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
moderationScore?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ghi chú kiểm duyệt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal file
346
apps/web/app/[locale]/(public)/bao-cao/[id]/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { ReportChartsGrid } from '@/components/reports/report-chart';
|
||||
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
|
||||
import { ReportTypeBadge } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useReport, useReportStatus } from '@/lib/hooks/use-reports';
|
||||
|
||||
// ─── Types for report content ──────────────────────────
|
||||
|
||||
interface SectionData {
|
||||
title?: string;
|
||||
content?: string;
|
||||
data?: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||
charts?: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||
projects?: Array<Record<string, unknown>>;
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export default function BaoCaoDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const reportId = params.id;
|
||||
|
||||
const { data: report, isLoading, isError, refetch } = useReport(reportId);
|
||||
|
||||
// Poll status while generating
|
||||
const isGenerating = report?.status === 'GENERATING';
|
||||
const { data: statusData } = useReportStatus(
|
||||
isGenerating ? reportId : null,
|
||||
isGenerating,
|
||||
);
|
||||
|
||||
// Refetch full report when status changes to READY
|
||||
React.useEffect(() => {
|
||||
if (statusData?.status === 'READY' || statusData?.status === 'FAILED') {
|
||||
refetch();
|
||||
}
|
||||
}, [statusData?.status, refetch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !report) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-4 text-lg font-medium">Không tìm thấy báo cáo</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Báo cáo không tồn tại hoặc đã bị xóa.
|
||||
</p>
|
||||
<Link href="/bao-cao">
|
||||
<Button variant="outline" className="mt-4 gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Quay lại danh sách
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createdDate = new Date(report.createdAt).toLocaleDateString('vi-VN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const sections = (report.content?.['sections'] as Record<string, SectionData>) ?? {};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/bao-cao"
|
||||
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Danh sách báo cáo
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<ReportTypeBadge type={report.type} />
|
||||
<ReportStatusBadge status={report.status} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{report.title}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{createdDate}</p>
|
||||
</div>
|
||||
|
||||
{report.pdfUrl && (
|
||||
<a href={report.pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Tải PDF
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generating state */}
|
||||
{report.status === 'GENERATING' && (
|
||||
<div className="rounded-lg border bg-blue-50 p-8 text-center">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
|
||||
<p className="mt-4 text-lg font-medium text-blue-900">
|
||||
Đang tạo báo cáo...
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-blue-700">
|
||||
Hệ thống AI đang phân tích dữ liệu và tạo báo cáo. Quá trình này
|
||||
có thể mất 1-3 phút.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed state */}
|
||||
{report.status === 'FAILED' && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
|
||||
<p className="mt-4 text-lg font-medium text-destructive">
|
||||
Tạo báo cáo thất bại
|
||||
</p>
|
||||
{report.errorMsg && (
|
||||
<p className="mt-1 text-sm text-destructive/80">
|
||||
{report.errorMsg}
|
||||
</p>
|
||||
)}
|
||||
<Link href="/bao-cao/tao-moi">
|
||||
<Button variant="outline" className="mt-4 gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Tạo báo cáo mới
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report content */}
|
||||
{report.status === 'READY' && report.content && (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(sections).map(([key, section]) => (
|
||||
<ReportSection key={key} sectionKey={key} section={section} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section renderer ──────────────────────────────────
|
||||
|
||||
function ReportSection({
|
||||
sectionKey,
|
||||
section,
|
||||
}: {
|
||||
sectionKey: string;
|
||||
section: SectionData;
|
||||
}) {
|
||||
const title = section.title || sectionKey;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">{title}</h2>
|
||||
|
||||
{/* Narrative text */}
|
||||
{section.content && (
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
{section.content.split('\n').map((paragraph, i) => (
|
||||
<p key={i} className="mb-2 last:mb-0">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
{section.charts && <ReportChartsGrid charts={section.charts} />}
|
||||
|
||||
{/* Data tables */}
|
||||
{section.data && <DataTablesSection data={section.data} />}
|
||||
|
||||
{/* Infrastructure projects */}
|
||||
{section.projects && section.projects.length > 0 && (
|
||||
<ProjectsTable projects={section.projects} />
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{section.summary && <SummaryBlock summary={section.summary} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data tables ───────────────────────────────────────
|
||||
|
||||
function DataTablesSection({
|
||||
data,
|
||||
}: {
|
||||
data: Record<string, Array<{ period: string; value: number; unit: string }>>;
|
||||
}) {
|
||||
const entries = Object.entries(data).filter(
|
||||
([, arr]) => Array.isArray(arr) && arr.length > 0,
|
||||
);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{entries.map(([key, items]) => {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (
|
||||
<div key={key} className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium" colSpan={3}>
|
||||
{label}
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||
Kỳ
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||
Giá trị
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
|
||||
Đơn vị
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
|
||||
<td className="px-3 py-1.5">{item.period}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{typeof item.value === 'number'
|
||||
? item.value.toLocaleString('vi-VN')
|
||||
: String(item.value)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">
|
||||
{item.unit}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Projects table ────────────────────────────────────
|
||||
|
||||
function ProjectsTable({
|
||||
projects,
|
||||
}: {
|
||||
projects: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Dự án</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Danh mục</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Vốn đầu tư (VND)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((p, i) => (
|
||||
<tr key={i} className="border-b last:border-0 even:bg-muted/30">
|
||||
<td className="px-3 py-1.5 font-medium">
|
||||
{String(p['name'] ?? '')}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">{String(p['category'] ?? '')}</td>
|
||||
<td className="px-3 py-1.5">{String(p['status'] ?? '')}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{p['investmentVND']
|
||||
? Number(p['investmentVND']).toLocaleString('vi-VN')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Summary block ─────────────────────────────────────
|
||||
|
||||
function SummaryBlock({ summary }: { summary: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg bg-muted/50 p-4">
|
||||
{Object.entries(summary).map(([key, val]) => {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
if (typeof val === 'number') {
|
||||
return (
|
||||
<p key={key} className="text-sm">
|
||||
<span className="font-medium">{label}:</span>{' '}
|
||||
{val.toLocaleString('vi-VN')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return (
|
||||
<div key={key} className="mt-2">
|
||||
<p className="text-sm font-medium">{label}:</p>
|
||||
<ul className="ml-4 mt-1 list-disc text-sm text-muted-foreground">
|
||||
{Object.entries(val as Record<string, unknown>).map(([k, v]) => (
|
||||
<li key={k}>
|
||||
{k}: {String(v)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal file
173
apps/web/app/[locale]/(public)/bao-cao/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, Plus, X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { ReportCard } from '@/components/reports/report-card';
|
||||
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useReportsList, useDeleteReport } from '@/lib/hooks/use-reports';
|
||||
import type { ReportType } from '@/lib/reports-api';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function BaoCaoPage() {
|
||||
const [typeFilter, setTypeFilter] = React.useState<ReportType | undefined>();
|
||||
const [page, setPage] = React.useState(1);
|
||||
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const { data, isLoading, isError } = useReportsList({
|
||||
type: typeFilter,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
|
||||
const deleteReport = useDeleteReport();
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0;
|
||||
|
||||
const handleTypeChange = (type: ReportType | undefined) => {
|
||||
setTypeFilter(type);
|
||||
setPage(1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteReport.mutate(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">Báo cáo</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Quản lý và tạo báo cáo phân tích bất động sản
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/bao-cao/tao-moi">
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo báo cáo mới
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Type filter tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto border-b" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={!typeFilter}
|
||||
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
!typeFilter
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => handleTypeChange(undefined)}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{REPORT_TYPES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
role="tab"
|
||||
aria-selected={typeFilter === value}
|
||||
className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
typeFilter === value
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => handleTypeChange(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{typeFilter && (
|
||||
<button
|
||||
className="ml-auto shrink-0 px-2 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleTypeChange(undefined)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Không thể tải danh sách báo cáo. Vui lòng thử lại.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setPage(page)}>
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
) : data && data.data.length > 0 ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{data.total} báo cáo
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.data.map((report) => (
|
||||
<ReportCard key={report.id} report={report} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-4 text-lg font-medium">Chưa có báo cáo nào</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Tạo báo cáo phân tích đầu tiên của bạn
|
||||
</p>
|
||||
<Link href="/bao-cao/tao-moi">
|
||||
<Button className="mt-4 gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo báo cáo mới
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
410
apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, ArrowRight, CheckCircle, FileText, Loader2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { REPORT_TYPES } from '@/components/reports/report-type-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useGenerateReport } from '@/lib/hooks/use-reports';
|
||||
import type { ReportType } from '@/lib/reports-api';
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────
|
||||
|
||||
const PROVINCES = [
|
||||
'Hồ Chí Minh', 'Hà Nội', 'Đà Nẵng', 'Bình Dương', 'Đồng Nai',
|
||||
'Long An', 'Bà Rịa - Vũng Tàu', 'Bắc Ninh', 'Hải Phòng', 'Hải Dương',
|
||||
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
|
||||
];
|
||||
|
||||
const HCM_DISTRICTS = [
|
||||
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
||||
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
|
||||
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
||||
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
|
||||
];
|
||||
|
||||
const PROPERTY_TYPES = [
|
||||
{ value: 'APARTMENT', label: 'Căn hộ' },
|
||||
{ value: 'HOUSE', label: 'Nhà phố' },
|
||||
{ value: 'VILLA', label: 'Biệt thự' },
|
||||
{ value: 'LAND', label: 'Đất nền' },
|
||||
{ value: 'COMMERCIAL', label: 'Thương mại' },
|
||||
];
|
||||
|
||||
// Wizard report types — subset users can create
|
||||
const WIZARD_REPORT_TYPES: ReportType[] = [
|
||||
'INDUSTRIAL_LOCATION',
|
||||
'RESIDENTIAL_MARKET',
|
||||
'DISTRICT_ANALYSIS',
|
||||
];
|
||||
|
||||
// ─── Steps ─────────────────────────────────────────────
|
||||
|
||||
type Step = 'select_type' | 'configure' | 'review';
|
||||
const STEPS: { key: Step; label: string }[] = [
|
||||
{ key: 'select_type', label: 'Chọn loại' },
|
||||
{ key: 'configure', label: 'Cấu hình' },
|
||||
{ key: 'review', label: 'Xác nhận' },
|
||||
];
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export default function TaoMoiPage() {
|
||||
const router = useRouter();
|
||||
const generateReport = useGenerateReport();
|
||||
|
||||
const [step, setStep] = React.useState<Step>('select_type');
|
||||
const [selectedType, setSelectedType] = React.useState<ReportType | null>(null);
|
||||
const [title, setTitle] = React.useState('');
|
||||
|
||||
// Params per type
|
||||
const [province, setProvince] = React.useState('');
|
||||
const [district, setDistrict] = React.useState('');
|
||||
const [propertyType, setPropertyType] = React.useState('');
|
||||
const [dateFrom, setDateFrom] = React.useState('');
|
||||
const [dateTo, setDateTo] = React.useState('');
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 'select_type':
|
||||
return !!selectedType;
|
||||
case 'configure':
|
||||
if (!title.trim()) return false;
|
||||
if (selectedType === 'INDUSTRIAL_LOCATION') return !!province;
|
||||
if (selectedType === 'RESIDENTIAL_MARKET') return !!district;
|
||||
if (selectedType === 'DISTRICT_ANALYSIS') return !!district;
|
||||
return true;
|
||||
case 'review':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (): Record<string, unknown> => {
|
||||
switch (selectedType) {
|
||||
case 'INDUSTRIAL_LOCATION':
|
||||
return { province };
|
||||
case 'RESIDENTIAL_MARKET':
|
||||
return {
|
||||
district,
|
||||
propertyType: propertyType || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
};
|
||||
case 'DISTRICT_ANALYSIS':
|
||||
return { district };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 'select_type') setStep('configure');
|
||||
else if (step === 'configure') setStep('review');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'configure') setStep('select_type');
|
||||
else if (step === 'review') setStep('configure');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedType) return;
|
||||
try {
|
||||
const result = await generateReport.mutateAsync({
|
||||
type: selectedType,
|
||||
title: title.trim(),
|
||||
params: buildParams(),
|
||||
});
|
||||
router.push(`/bao-cao/${result.reportId}`);
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTypeInfo = REPORT_TYPES.find((t) => t.value === selectedType);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold">Tạo báo cáo mới</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Chọn loại báo cáo và cấu hình thông số phân tích
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
i < stepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: i === stepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i < stepIndex ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-sm font-medium sm:inline ${
|
||||
i <= stepIndex ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`mx-2 h-0.5 flex-1 ${
|
||||
i < stepIndex ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
{/* Step 1: Select type */}
|
||||
{step === 'select_type' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Chọn loại báo cáo</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{WIZARD_REPORT_TYPES.map((typeValue) => {
|
||||
const info = REPORT_TYPES.find((t) => t.value === typeValue);
|
||||
if (!info) return null;
|
||||
const Icon = info.icon;
|
||||
return (
|
||||
<button
|
||||
key={typeValue}
|
||||
className={`flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors ${
|
||||
selectedType === typeValue
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted hover:border-muted-foreground/30'
|
||||
}`}
|
||||
onClick={() => setSelectedType(typeValue)}
|
||||
>
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{info.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
{step === 'configure' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Cấu hình báo cáo</h2>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Tiêu đề báo cáo</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Nhập tiêu đề báo cáo..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedType === 'INDUSTRIAL_LOCATION' && (
|
||||
<div>
|
||||
<Label htmlFor="province">Tỉnh/Thành phố</Label>
|
||||
<select
|
||||
id="province"
|
||||
value={province}
|
||||
onChange={(e) => setProvince(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn tỉnh/thành phố</option>
|
||||
{PROVINCES.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedType === 'RESIDENTIAL_MARKET' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="district">Quận/Huyện</Label>
|
||||
<select
|
||||
id="district"
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn quận/huyện</option>
|
||||
{HCM_DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="propertyType">Loại bất động sản</Label>
|
||||
<select
|
||||
id="propertyType"
|
||||
value={propertyType}
|
||||
onChange={(e) => setPropertyType(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Tất cả loại</option>
|
||||
{PROPERTY_TYPES.map((pt) => (
|
||||
<option key={pt.value} value={pt.value}>{pt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateFrom">Từ ngày</Label>
|
||||
<Input
|
||||
id="dateFrom"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dateTo">Đến ngày</Label>
|
||||
<Input
|
||||
id="dateTo"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedType === 'DISTRICT_ANALYSIS' && (
|
||||
<div>
|
||||
<Label htmlFor="district-analysis">Quận/Huyện</Label>
|
||||
<select
|
||||
id="district-analysis"
|
||||
value={district}
|
||||
onChange={(e) => setDistrict(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Chọn quận/huyện</option>
|
||||
{HCM_DISTRICTS.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review */}
|
||||
{step === 'review' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Xác nhận báo cáo</h2>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Loại báo cáo</span>
|
||||
<span className="text-sm font-medium">{selectedTypeInfo?.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tiêu đề</span>
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
|
||||
{province && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tỉnh/Thành phố</span>
|
||||
<span className="text-sm font-medium">{province}</span>
|
||||
</div>
|
||||
)}
|
||||
{district && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Quận/Huyện</span>
|
||||
<span className="text-sm font-medium">{district}</span>
|
||||
</div>
|
||||
)}
|
||||
{propertyType && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Loại BĐS</span>
|
||||
<span className="text-sm font-medium">
|
||||
{PROPERTY_TYPES.find((pt) => pt.value === propertyType)?.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dateFrom && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Từ ngày</span>
|
||||
<span className="text-sm font-medium">{dateFrom}</span>
|
||||
</div>
|
||||
)}
|
||||
{dateTo && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Đến ngày</span>
|
||||
<span className="text-sm font-medium">{dateTo}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{generateReport.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Không thể tạo báo cáo. Vui lòng thử lại.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={step === 'select_type'}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Quay lại
|
||||
</Button>
|
||||
|
||||
{step === 'review' ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={generateReport.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{generateReport.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
{generateReport.isPending ? 'Đang tạo...' : 'Tạo báo cáo'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="gap-2"
|
||||
>
|
||||
Tiếp tục
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { TransferWizardClient } from '@/components/chuyen-nhuong/transfer-wizard-client';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Đăng tin chuyển nhượng',
|
||||
description: 'Đăng tin chuyển nhượng nội thất, thiết bị hoặc mặt bằng',
|
||||
};
|
||||
|
||||
export default function DangTinPage() {
|
||||
return <TransferWizardClient />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
// Mock the server-side listing fetch
|
||||
vi.mock('@/lib/listings-server', () => ({
|
||||
fetchListingById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Avoid pulling in the heavy client component during unit tests
|
||||
vi.mock('@/components/listings/listing-detail-client', () => ({
|
||||
ListingDetailClient: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/seo/json-ld', () => ({
|
||||
JsonLd: () => null,
|
||||
generateBreadcrumbJsonLd: () => ({}),
|
||||
generateListingJsonLd: () => ({}),
|
||||
}));
|
||||
|
||||
import { fetchListingById } from '@/lib/listings-server';
|
||||
import { generateMetadata } from '../page';
|
||||
|
||||
const mockedFetch = vi.mocked(fetchListingById);
|
||||
|
||||
function buildListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
status: 'APPROVED',
|
||||
transactionType: 'SALE',
|
||||
priceVND: '3500000000',
|
||||
pricePerM2: null,
|
||||
rentPriceMonthly: null,
|
||||
commissionPct: null,
|
||||
viewCount: 0,
|
||||
saveCount: 0,
|
||||
inquiryCount: 0,
|
||||
publishedAt: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
property: {
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ cao cấp Quận 1',
|
||||
description: 'Đẹp, thoáng',
|
||||
address: '123 Lê Lợi',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
areaM2: 75,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: 1,
|
||||
direction: null,
|
||||
yearBuilt: null,
|
||||
legalStatus: null,
|
||||
amenities: null,
|
||||
projectName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
media: [
|
||||
{
|
||||
id: 'img-1',
|
||||
url: 'https://cdn.example.com/img1.jpg',
|
||||
type: 'image',
|
||||
order: 0,
|
||||
caption: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
seller: { id: 'u-1', fullName: 'Nguyen Van A', phone: '0900000000' },
|
||||
agent: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('listing page generateMetadata', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
});
|
||||
|
||||
it('returns a not-found title when the listing is missing', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(null);
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'vi', id: 'missing' }),
|
||||
});
|
||||
expect(meta.title).toMatch(/Không tìm thấy/);
|
||||
});
|
||||
|
||||
it('builds OG + Twitter tags with image, canonical and alternates', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(buildListing());
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'vi', id: 'listing-1' }),
|
||||
});
|
||||
|
||||
expect(meta.title).toContain('Căn hộ cao cấp Quận 1');
|
||||
expect(String(meta.description)).toContain('75 m');
|
||||
expect(String(meta.description)).toContain('2 PN');
|
||||
expect(String(meta.description)).toContain('Quận 1');
|
||||
|
||||
expect(meta.alternates?.canonical).toMatch(/\/vi\/listings\/listing-1$/);
|
||||
expect(meta.alternates?.languages?.vi).toMatch(/\/vi\/listings\/listing-1$/);
|
||||
expect(meta.alternates?.languages?.en).toMatch(/\/en\/listings\/listing-1$/);
|
||||
|
||||
const og = meta.openGraph as Record<string, unknown>;
|
||||
expect(og.type).toBe('article');
|
||||
expect(og.locale).toBe('vi_VN');
|
||||
expect(og.siteName).toBe('GoodGo');
|
||||
const ogImages = og.images as Array<{ url: string; width: number; height: number }>;
|
||||
expect(ogImages[0]?.url).toBe('https://cdn.example.com/img1.jpg');
|
||||
expect(ogImages[0]?.width).toBe(1200);
|
||||
expect(ogImages[0]?.height).toBe(630);
|
||||
|
||||
const twitter = meta.twitter as Record<string, unknown>;
|
||||
expect(twitter.card).toBe('summary_large_image');
|
||||
expect((twitter.images as string[])[0]).toBe('https://cdn.example.com/img1.jpg');
|
||||
|
||||
expect(meta.other?.['og:price:currency']).toBe('VND');
|
||||
expect(meta.other?.['og:price:amount']).toBe('3500000000');
|
||||
});
|
||||
|
||||
it('falls back to default OG image when no media is present', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
buildListing({
|
||||
property: { ...buildListing().property, media: [] },
|
||||
}),
|
||||
);
|
||||
const meta = await generateMetadata({
|
||||
params: Promise.resolve({ locale: 'en', id: 'listing-1' }),
|
||||
});
|
||||
|
||||
const og = meta.openGraph as Record<string, unknown>;
|
||||
expect(og.locale).toBe('en_US');
|
||||
const ogImages = og.images as Array<{ url: string }>;
|
||||
expect(ogImages[0]?.url).toBe('/og-image.png');
|
||||
});
|
||||
});
|
||||
21
apps/web/components/listings/__tests__/social-share.spec.tsx
Normal file
21
apps/web/components/listings/__tests__/social-share.spec.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SocialShare } from '../social-share';
|
||||
|
||||
describe('SocialShare', () => {
|
||||
it('renders Facebook, Zalo and copy-link actions', () => {
|
||||
render(<SocialShare listingId="abc-123" listingTitle="Căn hộ mẫu" />);
|
||||
expect(screen.getByLabelText('Chia sẻ lên Facebook')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Chia sẻ lên Zalo')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Sao chép liên kết')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the backend QR code image when toggled on', () => {
|
||||
render(<SocialShare listingId="abc-123" listingTitle="Căn hộ mẫu" />);
|
||||
const toggle = screen.getByLabelText('Hiện mã QR');
|
||||
fireEvent.click(toggle);
|
||||
const img = screen.getByAltText('Mã QR cho Căn hộ mẫu') as HTMLImageElement;
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img.src).toContain('/listings/abc-123/qr-code');
|
||||
});
|
||||
});
|
||||
41
apps/web/lib/inquiry-store.ts
Normal file
41
apps/web/lib/inquiry-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* UI state for the listing inquiry modal.
|
||||
*
|
||||
* Lives in a Zustand store so that:
|
||||
* - any component (e.g. floating CTAs, sticky "Nhắn tin" bars) can open the
|
||||
* modal without prop drilling through the listing detail tree
|
||||
* - tests and devtools can inspect / drive modal state directly
|
||||
*/
|
||||
export interface InquiryModalTarget {
|
||||
listingId: string;
|
||||
listingTitle: string;
|
||||
sellerName: string;
|
||||
}
|
||||
|
||||
export interface InquiryModalState {
|
||||
/** Whether the inquiry modal is currently open. */
|
||||
isOpen: boolean;
|
||||
/** The listing being inquired about (null when the modal is closed). */
|
||||
target: InquiryModalTarget | null;
|
||||
|
||||
/** Open the modal for a given listing. */
|
||||
openInquiry: (target: InquiryModalTarget) => void;
|
||||
/** Close the modal and clear the active target. */
|
||||
closeInquiry: () => void;
|
||||
/** Update open state directly (used by Radix onOpenChange). */
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useInquiryStore = create<InquiryModalState>((set) => ({
|
||||
isOpen: false,
|
||||
target: null,
|
||||
openInquiry: (target) => set({ isOpen: true, target }),
|
||||
closeInquiry: () => set({ isOpen: false, target: null }),
|
||||
setOpen: (open) =>
|
||||
set((state) => ({
|
||||
isOpen: open,
|
||||
target: open ? state.target : null,
|
||||
})),
|
||||
}));
|
||||
174
apps/web/lib/transfer-wizard-store.ts
Normal file
174
apps/web/lib/transfer-wizard-store.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { TransferCategory, TransferCondition, TransferPricingSource } from './chuyen-nhuong-api';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
export interface TransferItemDraft {
|
||||
id: string; // client-side only
|
||||
name: string;
|
||||
brand?: string;
|
||||
modelName?: string;
|
||||
condition: TransferCondition;
|
||||
purchaseYear?: number;
|
||||
originalPriceVND?: number;
|
||||
askingPriceVND: number;
|
||||
quantity: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AiEstimate {
|
||||
estimatedPriceVND: string;
|
||||
confidence: number;
|
||||
factors: unknown;
|
||||
}
|
||||
|
||||
export interface AiEstimateResult {
|
||||
estimates: AiEstimate[];
|
||||
totalEstimateVND: string;
|
||||
avgConfidence: number;
|
||||
}
|
||||
|
||||
export interface TransferWizardState {
|
||||
// Step tracking
|
||||
currentStep: number;
|
||||
|
||||
// Step 1: Category
|
||||
category: TransferCategory | null;
|
||||
|
||||
// Step 2: Items
|
||||
items: TransferItemDraft[];
|
||||
|
||||
// Step 2 (premises): Additional fields
|
||||
areaM2?: number;
|
||||
monthlyRentVND?: number;
|
||||
depositMonths?: number;
|
||||
remainingLeaseMo?: number;
|
||||
businessType?: string;
|
||||
footTraffic?: string;
|
||||
|
||||
// Step 3: AI estimate
|
||||
aiEstimate: AiEstimateResult | null;
|
||||
isEstimating: boolean;
|
||||
|
||||
// Step 4: Review & submit
|
||||
title: string;
|
||||
description: string;
|
||||
address: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
city: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
askingPriceVND: number;
|
||||
pricingSource: TransferPricingSource;
|
||||
isNegotiable: boolean;
|
||||
|
||||
// Actions
|
||||
setStep: (step: number) => void;
|
||||
setCategory: (category: TransferCategory) => void;
|
||||
addItem: (item: Omit<TransferItemDraft, 'id'>) => void;
|
||||
updateItem: (id: string, item: Partial<TransferItemDraft>) => void;
|
||||
removeItem: (id: string) => void;
|
||||
setPremisesFields: (fields: Partial<Pick<TransferWizardState, 'areaM2' | 'monthlyRentVND' | 'depositMonths' | 'remainingLeaseMo' | 'businessType' | 'footTraffic'>>) => void;
|
||||
setAiEstimate: (result: AiEstimateResult | null) => void;
|
||||
setIsEstimating: (loading: boolean) => void;
|
||||
setListingDetails: (details: Partial<Pick<TransferWizardState, 'title' | 'description' | 'address' | 'ward' | 'district' | 'city' | 'contactName' | 'contactPhone' | 'askingPriceVND' | 'pricingSource' | 'isNegotiable'>>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── Initial state ──────────────────────────────────────
|
||||
|
||||
const initialState = {
|
||||
currentStep: 0,
|
||||
category: null as TransferCategory | null,
|
||||
items: [] as TransferItemDraft[],
|
||||
areaM2: undefined,
|
||||
monthlyRentVND: undefined,
|
||||
depositMonths: undefined,
|
||||
remainingLeaseMo: undefined,
|
||||
businessType: undefined,
|
||||
footTraffic: undefined,
|
||||
aiEstimate: null as AiEstimateResult | null,
|
||||
isEstimating: false,
|
||||
title: '',
|
||||
description: '',
|
||||
address: '',
|
||||
ward: '',
|
||||
district: '',
|
||||
city: 'Hồ Chí Minh',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
askingPriceVND: 0,
|
||||
pricingSource: 'MANUAL' as TransferPricingSource,
|
||||
isNegotiable: true,
|
||||
};
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────
|
||||
|
||||
let nextItemId = 1;
|
||||
|
||||
export const useTransferWizardStore = create<TransferWizardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
setStep: (step) => set({ currentStep: step }),
|
||||
|
||||
setCategory: (category) => set({ category }),
|
||||
|
||||
addItem: (item) => {
|
||||
const id = `item-${nextItemId++}`;
|
||||
set((state) => ({ items: [...state.items, { ...item, id }] }));
|
||||
},
|
||||
|
||||
updateItem: (id, updates) =>
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.id === id ? { ...item, ...updates } : item,
|
||||
),
|
||||
})),
|
||||
|
||||
removeItem: (id) =>
|
||||
set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
|
||||
|
||||
setPremisesFields: (fields) => set(fields),
|
||||
|
||||
setAiEstimate: (result) => set({ aiEstimate: result }),
|
||||
|
||||
setIsEstimating: (isEstimating) => set({ isEstimating }),
|
||||
|
||||
setListingDetails: (details) => set(details),
|
||||
|
||||
reset: () => {
|
||||
nextItemId = 1;
|
||||
set(initialState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'goodgo-transfer-wizard',
|
||||
partialize: (state) => ({
|
||||
currentStep: state.currentStep,
|
||||
category: state.category,
|
||||
items: state.items,
|
||||
areaM2: state.areaM2,
|
||||
monthlyRentVND: state.monthlyRentVND,
|
||||
depositMonths: state.depositMonths,
|
||||
remainingLeaseMo: state.remainingLeaseMo,
|
||||
businessType: state.businessType,
|
||||
footTraffic: state.footTraffic,
|
||||
title: state.title,
|
||||
description: state.description,
|
||||
address: state.address,
|
||||
ward: state.ward,
|
||||
district: state.district,
|
||||
city: state.city,
|
||||
contactName: state.contactName,
|
||||
contactPhone: state.contactPhone,
|
||||
askingPriceVND: state.askingPriceVND,
|
||||
pricingSource: state.pricingSource,
|
||||
isNegotiable: state.isNegotiable,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
24
apps/web/lib/validations/inquiry.ts
Normal file
24
apps/web/lib/validations/inquiry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Vietnamese phone number rule:
|
||||
* - 9–11 digits, optional leading +84 or 0.
|
||||
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
||||
* string must be 9–11 digits (country code / leading zero stripped).
|
||||
*/
|
||||
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
|
||||
|
||||
export const inquiryFormSchema = z.object({
|
||||
message: z
|
||||
.string({ error: 'Vui lòng nhập nội dung tin nhắn' })
|
||||
.trim()
|
||||
.min(1, 'Vui lòng nhập nội dung tin nhắn')
|
||||
.max(2000, 'Tin nhắn không được vượt quá 2000 ký tự'),
|
||||
phone: z
|
||||
.string({ error: 'Vui lòng nhập số điện thoại' })
|
||||
.trim()
|
||||
.min(9, 'Vui lòng nhập số điện thoại hợp lệ')
|
||||
.regex(PHONE_REGEX, 'Số điện thoại không hợp lệ'),
|
||||
});
|
||||
|
||||
export type InquiryFormData = z.infer<typeof inquiryFormSchema>;
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user