feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -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>;
}

View File

@@ -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', () => {

View File

@@ -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,
) {}
}

View File

@@ -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');
});
});
});

View File

@@ -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),
);
}
}

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
export class GetProjectQuery {
constructor(public readonly slugOrId: string) {}
}

View File

@@ -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,
});
}
}

View File

@@ -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,
) {}
}

View File

@@ -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();
}
}

View File

@@ -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>>;
}

View 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';

View File

@@ -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;
}

View File

@@ -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,
),
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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 {}

View File

@@ -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('&lt;script&gt;');
});
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' },
}),
);
});
});

View File

@@ -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');
});
});

View File

@@ -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',
);
}
}
}

View File

@@ -0,0 +1,7 @@
export class GenerateTransferUploadUrlsCommand {
constructor(
public readonly sellerId: string,
public readonly listingId: string | null,
public readonly files: { fileName: string; mimeType: string }[],
) {}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
export { ModerateTransferListingCommand } from './moderate-transfer-listing.command';
export { ModerateTransferListingHandler } from './moderate-transfer-listing.handler';

View File

@@ -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,
) {}
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,2 @@
export { ListPendingTransfersQuery } from './list-pending-transfers.query';
export { ListPendingTransfersHandler } from './list-pending-transfers.handler';

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,6 @@
export class ListPendingTransfersQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -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[];
}

View File

@@ -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;
}

View 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 tạo báo cáo. Quá trình này
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 (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>
);
}

View 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 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 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>
);
}

View 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 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>
);
}

View File

@@ -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 />;
}

View File

@@ -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');
});
});

View 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');
});
});

View 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,
})),
}));

View 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,
}),
},
),
);

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
/**
* Vietnamese phone number rule:
* - 911 digits, optional leading +84 or 0.
* We keep validation pragmatic: whitespace is stripped, then the remaining
* string must be 911 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

View File

@@ -0,0 +1,46 @@
import { test, expect, registerUser } from '../fixtures';
/**
* Admin Payments E2E tests (TEC-2749).
*
* Verifies authorization on POST /admin/payments/:id/confirm-transfer.
* Full happy-path flow (confirm → payment.COMPLETED + audit log) requires
* a seeded admin + pending bank-transfer payment and is exercised in
* the handler unit tests.
*/
test.describe('Admin Payments API — Authorization', () => {
let regularToken: string;
test.beforeAll(async ({ request }) => {
const { accessToken } = await registerUser(request);
regularToken = accessToken;
});
test.describe('POST /admin/payments/:id/confirm-transfer — Confirm bank transfer', () => {
test('rejects unauthenticated request', async ({ request }) => {
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
data: { bankReference: 'FT123456' },
});
expect(res.status()).toBe(401);
});
test('rejects non-admin user', async ({ request }) => {
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
data: { bankReference: 'FT123456' },
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
test('rejects non-admin user with empty body', async ({ request }) => {
const res = await request.post('admin/payments/test-payment-id/confirm-transfer', {
data: {},
headers: { Authorization: `Bearer ${regularToken}` },
});
expect(res.status()).toBe(403);
});
});
});

View File

@@ -287,4 +287,46 @@ test.describe('Listings API', () => {
expect(res.status()).toBe(401);
});
});
test.describe('GET /listings/:id/price-history — Price history timeline', () => {
test('returns empty array when listing has no price changes', async ({ request }) => {
const { listing } = await createListing(request, accessToken);
const res = await request.get(`listings/${listing.listingId}/price-history`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(0);
});
test('returns a price history entry after price update', async ({ request }) => {
const { listing } = await createListing(request, accessToken);
const updateRes = await request.patch(`listings/${listing.listingId}`, {
data: { priceVND: '6000000000' },
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(updateRes.status()).toBe(200);
// Event bus is async — poll briefly for the snapshot to land.
const deadline = Date.now() + 5000;
let body: Array<{ oldPrice: string; newPrice: string; source: string; changedAt: string }> = [];
while (Date.now() < deadline) {
const res = await request.get(`listings/${listing.listingId}/price-history`);
expect(res.status()).toBe(200);
body = await res.json();
if (body.length > 0) break;
await new Promise((r) => setTimeout(r, 100));
}
expect(body.length).toBeGreaterThanOrEqual(1);
const entry = body[0]!;
expect(entry).toHaveProperty('oldPrice');
expect(entry).toHaveProperty('newPrice');
expect(entry).toHaveProperty('source');
expect(entry).toHaveProperty('changedAt');
expect(entry.source).toBe('manual_update');
});
});
});

View File

@@ -0,0 +1,193 @@
import { test, expect } from '@playwright/test';
/**
* E2E coverage for the listing inquiry modal (TEC-2751 / TEC-2738.10).
*
* The backend route is `POST /api/v1/inquiries` and is guarded by JwtAuthGuard.
* The web app talks to it via `apiClient.post('/inquiries', ...)`, so the
* request URL contains `/inquiries` — we intercept it and stub both the
* profile fetch (to make the user look authenticated) and the inquiry POST.
*/
const mockListing = {
id: 'listing-1',
transactionType: 'SALE',
priceVND: '5000000000',
pricePerM2: 66666667,
rentPriceMonthly: null,
commissionPct: 2.5,
status: 'ACTIVE',
viewCount: 120,
saveCount: 15,
inquiryCount: 8,
publishedAt: '2026-01-15T00:00:00Z',
property: {
id: 'prop-1',
propertyType: 'APARTMENT',
title: 'Căn hộ cao cấp Quận 1',
description: 'Căn hộ đẹp view sông Sài Gòn.',
address: '123 Nguyễn Huệ',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
latitude: 10.7769,
longitude: 106.7009,
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: 1,
direction: 'SOUTH',
yearBuilt: 2022,
legalStatus: 'Sổ hồng',
projectName: 'Vinhomes Central Park',
amenities: ['Hồ bơi'],
media: [{ id: 'm1', url: '/placeholder.jpg', type: 'IMAGE', order: 0 }],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: { id: 'a1', agency: 'GoodGo Realty', licenseNumber: 'AGT-001' },
};
const mockProfile = {
id: 'user-1',
email: 'buyer@example.com',
fullName: 'Buyer Test',
phone: '0911222333',
role: 'USER',
};
test.describe('Listing inquiry modal', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/listings/listing-1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListing),
}),
);
});
test('opens the inquiry modal when clicking "Nhắn tin"', async ({ page }) => {
await page.goto('/listings/listing-1');
await expect(
page.getByRole('heading', { name: 'Căn hộ cao cấp Quận 1' }),
).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /Nhan tin/i }).click();
await expect(
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
).toBeVisible();
await expect(page.getByLabel(/Nội dung tin nhắn/)).toBeVisible();
await expect(page.getByLabel(/Số điện thoại/)).toBeVisible();
});
test('shows validation errors when fields are missing or invalid', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
// Submit empty form — zod should flag both fields.
await page.getByRole('button', { name: 'Gửi tin nhắn' }).click();
await expect(page.getByText('Vui lòng nhập nội dung tin nhắn')).toBeVisible();
// Provide message but an obviously-invalid phone.
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
await page.getByLabel(/Số điện thoại/).fill('123');
await page.getByRole('button', { name: 'Gửi tin nhắn' }).click();
await expect(
page.getByText(/Vui lòng nhập số điện thoại hợp lệ|Số điện thoại không hợp lệ/),
).toBeVisible();
});
test('submits the inquiry and calls POST /api/v1/inquiries (201)', async ({
page,
context,
}) => {
// Mark the user as authenticated for the client-side check in auth-store.
await context.addCookies([
{
name: 'goodgo_authenticated',
value: '1',
url: 'http://localhost:3000',
},
]);
// Stub the profile load so useAuthStore.isAuthenticated flips to true.
await page.route('**/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockProfile),
}),
);
let inquiryRequestBody: Record<string, unknown> | null = null;
await page.route('**/inquiries', async (route) => {
if (route.request().method() !== 'POST') {
return route.fallback();
}
inquiryRequestBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'inq-1',
listingId: 'listing-1',
listingTitle: mockListing.property.title,
userId: mockProfile.id,
userName: mockProfile.fullName,
userPhone: mockProfile.phone,
message: 'Tôi quan tâm tin đăng này.',
phone: '0911222333',
isRead: false,
createdAt: new Date().toISOString(),
}),
});
});
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
// Phone pre-fills from the mocked profile; overwrite to ensure stability.
await page.getByLabel(/Số điện thoại/).fill('0911222333');
const [request] = await Promise.all([
page.waitForRequest(
(req) => req.url().includes('/inquiries') && req.method() === 'POST',
),
page.getByRole('button', { name: 'Gửi tin nhắn' }).click(),
]);
expect(request.postDataJSON()).toMatchObject({
listingId: 'listing-1',
message: 'Tôi quan tâm tin đăng này.',
phone: '0911222333',
});
// Modal should close after success.
await expect(
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
).toBeHidden();
// Sonner success toast appears.
await expect(page.getByText('Đã gửi thành công!')).toBeVisible();
expect(inquiryRequestBody).not.toBeNull();
});
test('redirects anonymous users to /login on submit', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
await page.getByLabel(/Số điện thoại/).fill('0911222333');
await Promise.all([
page.waitForURL(/\/login/),
page.getByRole('button', { name: 'Gửi tin nhắn' }).click(),
]);
await expect(page).toHaveURL(/\/login/);
});
});

View File

@@ -1,12 +1,13 @@
"""Industrial AVM — Rent estimation service for industrial parks.
Heuristic fallback when trained models are not available.
Uses gradient boosting approach similar to residential AVM v2.
Preference order: park-level ridge baseline (v1, TEC-2768) → XGBoost → heuristic.
Heuristic fallback remains when no trained artifact is on disk.
"""
import logging
import math
import os
from datetime import datetime, timezone
import pickle
from typing import Any
import numpy as np
@@ -20,6 +21,21 @@ from app.models.avm_industrial import (
logger = logging.getLogger(__name__)
RIDGE_ARTIFACT_NAME = "avm_industrial_park_ridge_v1.pkl"
# Map API property types to the rent head trained in the ridge baseline.
# Land rent is stored as USD/m²/year; others as USD/m²/month — convert where
# needed so the response stays in USD/m²/month.
_PROPERTY_TO_HEAD: dict[str, str] = {
"warehouse": "rbw",
"ready_built_warehouse": "rbw",
"factory": "rbf",
"ready_built_factory": "rbf",
"office_in_park": "rbf",
"open_yard": "land",
"industrial_land": "land",
}
# ── Feature ordering for model input ────────────────────────────
INDUSTRIAL_FEATURE_NAMES = [
"region_encoded",
@@ -169,40 +185,171 @@ def _find_comparables(req: IndustrialAVMRequest) -> list[IndustrialComparable]:
class IndustrialAVMService:
"""Industrial property rent estimation service.
Uses gradient boosting when a trained model is available,
falls back to heuristic pricing for development/demo.
Preference order when a trained artifact is available:
1. Ridge v1 (park-level baseline with conformal CIs, TEC-2768)
2. XGBoost (legacy, listing-level)
3. Multi-factor heuristic (always available)
"""
def __init__(self) -> None:
self._model: Any = None
self._model_version = "heuristic-v1"
self._backend: str = "heuristic"
self._load_model()
def _load_model(self) -> None:
"""Attempt to load trained industrial AVM model."""
"""Attempt to load trained industrial AVM artifacts (ridge first)."""
try:
from app.config import settings
model_path = settings.model_path
except Exception:
logger.info("Industrial AVM: config unavailable — using heuristic")
return
ridge_path = os.path.join(model_path, RIDGE_ARTIFACT_NAME)
if os.path.exists(ridge_path):
try:
with open(ridge_path, "rb") as f:
artifact = pickle.load(f)
if not isinstance(artifact, dict) or artifact.get("version") != "ridge-industrial-v1":
raise ValueError(f"Unexpected artifact version in {ridge_path}")
self._model = artifact
self._model_version = "ridge-industrial-v1"
self._backend = "ridge"
logger.info("Loaded industrial AVM ridge artifact from %s", ridge_path)
return
except Exception as exc: # keep service alive on artifact corruption
logger.warning("Failed to load ridge artifact (%s); falling back", exc)
try:
import xgboost as xgb
from app.config import settings
path = os.path.join(settings.model_path, "avm_industrial_xgb.json")
if os.path.exists(path):
xgb_path = os.path.join(model_path, "avm_industrial_xgb.json")
if os.path.exists(xgb_path):
booster = xgb.Booster()
booster.load_model(path)
booster.load_model(xgb_path)
self._model = booster
self._model_version = "xgb-industrial-v1"
logger.info("Loaded industrial AVM model from %s", path)
else:
logger.info("No trained industrial AVM model — using heuristic")
self._backend = "xgb"
logger.info("Loaded industrial AVM xgb model from %s", xgb_path)
return
except Exception:
logger.info("Industrial AVM model not available — using heuristic")
pass
logger.info("No trained industrial AVM model — using heuristic")
def predict(self, req: IndustrialAVMRequest) -> IndustrialAVMResponse:
"""Predict industrial property rent."""
if self._model is not None:
if self._backend == "ridge":
return self._predict_ridge(req)
if self._backend == "xgb" and self._model is not None:
return self._predict_model(req)
return self._predict_heuristic(req)
def _featureize_ridge(self, req: IndustrialAVMRequest, spec: dict) -> np.ndarray:
"""Build the exact feature vector used during ridge training.
Feature ordering must match `spec["feature_cols"]` which is the canonical
order emitted by the trainer. Sources:
- numeric fields come straight from the request
- province FDI comes from the artifact lookup (fallback to default)
- target-industry flags approximate one-hots against top-6 list
"""
province = (req.province or "").strip().lower()
fdi = spec["province_fdi"].get(province, spec["default_fdi"])
occupancy = float(req.park_occupancy_rate)
if occupancy > 1.5:
occupancy = occupancy / 100.0
occupancy = min(max(occupancy, 0.0), 1.0)
feats: dict[str, float] = {
"occupancy": occupancy,
"log_area_ha": math.log1p(max(0.0, float(req.park_area_ha))),
"park_age_years": float(max(0, int(req.park_age_years))),
"log_dist_port_km": math.log1p(max(0.0, float(req.distance_to_port_km))),
"log_dist_airport_km": math.log1p(max(0.0, float(req.distance_to_airport_km))),
"log_dist_highway_km": math.log1p(max(0.0, float(req.distance_to_highway_km))),
"logistics_connectivity_score": float(req.logistics_connectivity_score),
"log_fdi_province": math.log1p(
max(0.0, float(req.fdi_province_musd) or fdi)
),
"has_special_zone": float(
req.zoning.lower() in {"free_trade_zone", "high_tech"}
),
}
# Property type flags can proxy certain target-industry signals but the
# trainer's industry one-hots are park-level. At inference we don't know
# the park's industry mix, so default to 0 and let the province/region
# fixed effects carry the signal.
for ind in spec["top_industries"]:
feats[f"ind_{ind}"] = 0.0
region = (req.region or "south").lower()
for r in spec["region_order"][1:]:
feats[f"region_{r}"] = float(region == r)
vec = np.array([feats[c] for c in spec["feature_cols"]], dtype=np.float64)
return vec
def _predict_ridge(self, req: IndustrialAVMRequest) -> IndustrialAVMResponse:
"""Predict using the ridge v1 park-level baseline (conformal CIs)."""
artifact = self._model
spec = artifact["feature_spec"]
x = self._featureize_ridge(req, spec)
head_name = _PROPERTY_TO_HEAD.get(req.property_type.lower(), "rbf")
head = artifact["heads"][head_name]
x_std = (x - head["scaler_mean"]) / np.where(
head["scaler_scale"] == 0, 1.0, head["scaler_scale"]
)
log_pred = float(x_std @ head["coefficients"] + head["intercept"])
q80 = float(head["q80_log"])
# Ridge head is trained in natural units (USD/m²/month for rbf/rbw,
# USD/m²/year for land). Convert to the response contract which always
# reports monthly USD/m² for the primary estimate.
rent_native = math.expm1(log_pred)
low_native = math.expm1(log_pred - q80)
high_native = math.expm1(log_pred + q80)
if head_name == "land":
rent = rent_native / 12.0
low = low_native / 12.0
high = high_native / 12.0
else:
rent = rent_native
low = low_native
high = high_native
comparables = _find_comparables(req)
# Drivers: top coefficients by absolute standardized contribution.
contrib = head["coefficients"] * x_std
order = np.argsort(-np.abs(contrib))[:8]
total = float(np.sum(np.abs(contrib))) or 1.0
drivers = [
FeatureImportance(
feature=head["feature_cols"][i],
importance=round(float(abs(contrib[i]) / total), 4),
)
for i in order
if abs(contrib[i]) > 1e-6
]
return IndustrialAVMResponse(
estimated_rent_usd_m2=round(max(0.0, rent), 2),
confidence=round(float(head.get("coverage_80_loo", 0.80)), 2),
rent_range_low_usd_m2=round(max(0.0, low), 2),
rent_range_high_usd_m2=round(max(0.0, high), 2),
annual_rent_usd_m2=round(max(0.0, rent) * 12, 2),
total_monthly_rent_usd=round(max(0.0, rent) * req.area_m2, 2),
comparables=comparables,
drivers=drivers,
model_version=self._model_version,
)
def _predict_model(self, req: IndustrialAVMRequest) -> IndustrialAVMResponse:
"""Predict using trained gradient boosting model."""
import xgboost as xgb

View File

@@ -0,0 +1,422 @@
[
{
"id": "seed-kcn-001",
"name": "KCN VSIP Bắc Ninh",
"slug": "vsip-bac-ninh",
"province": "Bắc Ninh",
"region": "north",
"status": "operational",
"totalAreaHa": 700,
"occupancyRate": 92,
"establishedYear": 2007,
"landRentUsdM2Year": 90,
"rbfRentUsdM2Month": 5.5,
"rbwRentUsdM2Month": 4.8,
"connectivity": {
"nearestPort": {"name": "Cảng Hải Phòng", "distanceKm": 110},
"airport": {"name": "Nội Bài", "distanceKm": 35},
"highway": {"name": "QL 1A", "distanceKm": 5}
},
"incentives": {"specialZone": false},
"targetIndustries": ["electronics", "automotive", "precision engineering", "food processing"]
},
{
"id": "seed-kcn-002",
"name": "KCN VSIP Bình Dương I",
"slug": "vsip-binh-duong-1",
"province": "Bình Dương",
"region": "south",
"status": "full",
"totalAreaHa": 500,
"occupancyRate": 100,
"establishedYear": 1996,
"landRentUsdM2Year": 110,
"rbfRentUsdM2Month": 6.0,
"rbwRentUsdM2Month": 5.2,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 25},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 20},
"highway": {"name": "ĐL Mỹ Phước - Tân Vạn", "distanceKm": 2}
},
"incentives": {"specialZone": false},
"targetIndustries": ["electronics", "garment", "food processing", "logistics"]
},
{
"id": "seed-kcn-003",
"name": "KCN Amata Đồng Nai",
"slug": "amata-dong-nai",
"province": "Đồng Nai",
"region": "south",
"status": "operational",
"totalAreaHa": 700,
"occupancyRate": 88,
"establishedYear": 1994,
"landRentUsdM2Year": 95,
"rbfRentUsdM2Month": 5.0,
"rbwRentUsdM2Month": 4.5,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 30},
"airport": {"name": "Long Thành", "distanceKm": 25},
"highway": {"name": "QL 1A", "distanceKm": 2}
},
"incentives": {"specialZone": false},
"targetIndustries": ["automotive", "electronics", "chemicals", "machinery"]
},
{
"id": "seed-kcn-004",
"name": "KCN Amata Long An",
"slug": "amata-long-an",
"province": "Long An",
"region": "south",
"status": "under_construction",
"totalAreaHa": 410,
"occupancyRate": 35,
"establishedYear": 2020,
"landRentUsdM2Year": 75,
"rbfRentUsdM2Month": 4.5,
"rbwRentUsdM2Month": 3.8,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 45},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 35},
"highway": {"name": "Vành đai 3 TP.HCM", "distanceKm": 8}
},
"incentives": {"specialZone": true},
"targetIndustries": ["logistics", "food processing", "consumer goods", "light manufacturing"]
},
{
"id": "seed-kcn-005",
"name": "KCN Nam Đình Vũ",
"slug": "nam-dinh-vu",
"province": "Hải Phòng",
"region": "north",
"status": "operational",
"totalAreaHa": 1329,
"occupancyRate": 75,
"establishedYear": 2014,
"landRentUsdM2Year": 80,
"rbfRentUsdM2Month": 4.8,
"rbwRentUsdM2Month": 4.0,
"connectivity": {
"nearestPort": {"name": "Cảng Đình Vũ", "distanceKm": 2},
"airport": {"name": "Cát Bi", "distanceKm": 15},
"highway": {"name": "Cao tốc Hà Nội - Hải Phòng", "distanceKm": 10}
},
"incentives": {"specialZone": true},
"targetIndustries": ["petrochemicals", "logistics", "heavy industry", "steel"]
},
{
"id": "seed-kcn-006",
"name": "KCN Long Hậu",
"slug": "long-hau",
"province": "Long An",
"region": "south",
"status": "operational",
"totalAreaHa": 311,
"occupancyRate": 85,
"establishedYear": 2006,
"landRentUsdM2Year": 85,
"rbfRentUsdM2Month": 4.5,
"rbwRentUsdM2Month": 3.8,
"connectivity": {
"nearestPort": {"name": "Cảng Hiệp Phước", "distanceKm": 5},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 25},
"highway": {"name": "Nguyễn Hữu Thọ", "distanceKm": 3}
},
"incentives": {"specialZone": false},
"targetIndustries": ["logistics", "food processing", "garment", "packaging"]
},
{
"id": "seed-kcn-007",
"name": "KCN Tân Thuận (EPZ)",
"slug": "tan-thuan-epz",
"province": "TP. Hồ Chí Minh",
"region": "south",
"status": "full",
"totalAreaHa": 300,
"occupancyRate": 100,
"establishedYear": 1991,
"landRentUsdM2Year": 130,
"rbfRentUsdM2Month": 7.0,
"rbwRentUsdM2Month": 6.0,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 15},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 12},
"highway": {"name": "Nguyễn Văn Linh", "distanceKm": 1}
},
"incentives": {"specialZone": true},
"targetIndustries": ["electronics", "precision engineering", "software", "export manufacturing"]
},
{
"id": "seed-kcn-008",
"name": "KCN Thăng Long",
"slug": "thang-long",
"province": "Hà Nội",
"region": "north",
"status": "full",
"totalAreaHa": 274,
"occupancyRate": 100,
"establishedYear": 1997,
"landRentUsdM2Year": 105,
"rbfRentUsdM2Month": 6.0,
"rbwRentUsdM2Month": 5.0,
"connectivity": {
"nearestPort": {"name": "Cảng Hải Phòng", "distanceKm": 120},
"airport": {"name": "Nội Bài", "distanceKm": 16},
"highway": {"name": "Nội Bài - Lào Cai", "distanceKm": 5}
},
"incentives": {"specialZone": false},
"targetIndustries": ["electronics", "automotive", "precision mechanics", "IT"]
},
{
"id": "seed-kcn-009",
"name": "KCN KTG Industrial Nhơn Trạch",
"slug": "ktg-nhon-trach",
"province": "Đồng Nai",
"region": "south",
"status": "operational",
"totalAreaHa": 250,
"occupancyRate": 78,
"establishedYear": 2018,
"landRentUsdM2Year": 80,
"rbfRentUsdM2Month": 4.8,
"rbwRentUsdM2Month": 4.0,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 20},
"airport": {"name": "Long Thành", "distanceKm": 15},
"highway": {"name": "Cao tốc Long Thành - Dầu Giây", "distanceKm": 5}
},
"incentives": {"specialZone": false},
"targetIndustries": ["logistics", "e-commerce fulfillment", "light manufacturing", "food processing"]
},
{
"id": "seed-kcn-010",
"name": "KCN Prodezi Nhơn Trạch",
"slug": "prodezi-nhon-trach",
"province": "Đồng Nai",
"region": "south",
"status": "operational",
"totalAreaHa": 340,
"occupancyRate": 70,
"establishedYear": 2015,
"landRentUsdM2Year": 72,
"rbfRentUsdM2Month": 4.2,
"rbwRentUsdM2Month": 3.5,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 25},
"airport": {"name": "Long Thành", "distanceKm": 12},
"highway": {"name": "QL 51", "distanceKm": 8}
},
"incentives": {"specialZone": false},
"targetIndustries": ["machinery", "plastics", "packaging", "consumer goods"]
},
{
"id": "seed-kcn-011",
"name": "KCN Thăng Long II Hưng Yên",
"slug": "thang-long-2-hung-yen",
"province": "Hưng Yên",
"region": "north",
"status": "operational",
"totalAreaHa": 345,
"occupancyRate": 82,
"establishedYear": 2004,
"landRentUsdM2Year": 78,
"rbfRentUsdM2Month": 4.5,
"rbwRentUsdM2Month": 3.8,
"connectivity": {
"nearestPort": {"name": "Cảng Hải Phòng", "distanceKm": 85},
"airport": {"name": "Nội Bài", "distanceKm": 50},
"highway": {"name": "QL 5", "distanceKm": 3}
},
"incentives": {"specialZone": false},
"targetIndustries": ["electronics", "automotive parts", "precision engineering"]
},
{
"id": "seed-kcn-012",
"name": "KCN Yên Phong Bắc Ninh",
"slug": "yen-phong-bac-ninh",
"province": "Bắc Ninh",
"region": "north",
"status": "operational",
"totalAreaHa": 658,
"occupancyRate": 95,
"establishedYear": 2008,
"landRentUsdM2Year": 85,
"rbfRentUsdM2Month": 5.0,
"rbwRentUsdM2Month": 4.2,
"connectivity": {
"nearestPort": {"name": "Cảng Hải Phòng", "distanceKm": 100},
"airport": {"name": "Nội Bài", "distanceKm": 30},
"highway": {"name": "QL 18", "distanceKm": 5}
},
"incentives": {"specialZone": false},
"targetIndustries": ["electronics", "display manufacturing", "semiconductors", "automotive"]
},
{
"id": "seed-kcn-013",
"name": "KCN Bà Rịa - Vũng Tàu",
"slug": "ba-ria-vung-tau",
"province": "Bà Rịa - Vũng Tàu",
"region": "south",
"status": "operational",
"totalAreaHa": 450,
"occupancyRate": 72,
"establishedYear": 2002,
"landRentUsdM2Year": 65,
"rbfRentUsdM2Month": 3.8,
"rbwRentUsdM2Month": 3.2,
"connectivity": {
"nearestPort": {"name": "Cảng Cái Mép - Thị Vải", "distanceKm": 20},
"airport": {"name": "Long Thành", "distanceKm": 50},
"highway": {"name": "Cao tốc Biên Hòa - Vũng Tàu", "distanceKm": 5}
},
"incentives": {"specialZone": true},
"targetIndustries": ["oil & gas", "petrochemicals", "heavy industry", "steel", "logistics"]
},
{
"id": "seed-kcn-014",
"name": "KCN Becamex Bình Phước",
"slug": "becamex-binh-phuoc",
"province": "Bình Phước",
"region": "south",
"status": "under_construction",
"totalAreaHa": 4686,
"occupancyRate": 25,
"establishedYear": 2021,
"landRentUsdM2Year": 50,
"rbfRentUsdM2Month": 3.5,
"rbwRentUsdM2Month": 3.0,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 85},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 80},
"highway": {"name": "QL 13", "distanceKm": 3}
},
"incentives": {"specialZone": true},
"targetIndustries": ["agriculture processing", "rubber", "wood processing", "light manufacturing"]
},
{
"id": "seed-kcn-015",
"name": "KCN Đại An Hải Dương",
"slug": "dai-an-hai-duong",
"province": "Hải Dương",
"region": "north",
"status": "operational",
"totalAreaHa": 174,
"occupancyRate": 90,
"establishedYear": 2003,
"landRentUsdM2Year": 70,
"rbfRentUsdM2Month": 4.2,
"rbwRentUsdM2Month": 3.5,
"connectivity": {
"nearestPort": {"name": "Cảng Hải Phòng", "distanceKm": 50},
"airport": {"name": "Nội Bài", "distanceKm": 60},
"highway": {"name": "QL 5", "distanceKm": 2}
},
"incentives": {"specialZone": false},
"targetIndustries": ["garment", "food processing", "mechanics", "electronics assembly"]
},
{
"id": "seed-kcn-016",
"name": "KCN DEEP C Hải Phòng",
"slug": "deep-c-hai-phong",
"province": "Hải Phòng",
"region": "north",
"status": "operational",
"totalAreaHa": 3000,
"occupancyRate": 68,
"establishedYear": 1997,
"landRentUsdM2Year": 75,
"rbfRentUsdM2Month": 4.5,
"rbwRentUsdM2Month": 3.8,
"connectivity": {
"nearestPort": {"name": "Cảng Đình Vũ", "distanceKm": 5},
"airport": {"name": "Cát Bi", "distanceKm": 12},
"highway": {"name": "Cao tốc Hà Nội - Hải Phòng", "distanceKm": 8}
},
"incentives": {"specialZone": true},
"targetIndustries": ["petrochemicals", "LNG", "electronics", "logistics", "renewable energy"]
},
{
"id": "seed-kcn-017",
"name": "KCN Mỹ Phước 3 Bình Dương",
"slug": "my-phuoc-3-binh-duong",
"province": "Bình Dương",
"region": "south",
"status": "operational",
"totalAreaHa": 992,
"occupancyRate": 87,
"establishedYear": 2006,
"landRentUsdM2Year": 82,
"rbfRentUsdM2Month": 4.8,
"rbwRentUsdM2Month": 4.0,
"connectivity": {
"nearestPort": {"name": "Cảng Cát Lái", "distanceKm": 40},
"airport": {"name": "Tân Sơn Nhất", "distanceKm": 35},
"highway": {"name": "Mỹ Phước - Tân Vạn", "distanceKm": 1}
},
"incentives": {"specialZone": false},
"targetIndustries": ["furniture", "garment", "food processing", "electronics assembly", "plastics"]
},
{
"id": "seed-kcn-018",
"name": "KCN Phú Mỹ 2 BRVT",
"slug": "phu-my-2-brvt",
"province": "Bà Rịa - Vũng Tàu",
"region": "south",
"status": "operational",
"totalAreaHa": 380,
"occupancyRate": 65,
"establishedYear": 2007,
"landRentUsdM2Year": 55,
"rbfRentUsdM2Month": 3.5,
"rbwRentUsdM2Month": 3.0,
"connectivity": {
"nearestPort": {"name": "Cảng Cái Mép - Thị Vải", "distanceKm": 10},
"airport": {"name": "Long Thành", "distanceKm": 40},
"highway": {"name": "QL 51", "distanceKm": 3}
},
"incentives": {"specialZone": true},
"targetIndustries": ["petrochemicals", "steel", "power generation", "port logistics"]
},
{
"id": "seed-kcn-019",
"name": "KCN WHA Nghệ An",
"slug": "wha-nghe-an",
"province": "Nghệ An",
"region": "central",
"status": "under_construction",
"totalAreaHa": 498,
"occupancyRate": 15,
"establishedYear": 2022,
"landRentUsdM2Year": 45,
"rbfRentUsdM2Month": 3.0,
"rbwRentUsdM2Month": 2.5,
"connectivity": {
"nearestPort": {"name": "Cảng Cửa Lò", "distanceKm": 15},
"airport": {"name": "Vinh", "distanceKm": 20},
"highway": {"name": "QL 1A", "distanceKm": 5}
},
"incentives": {"specialZone": true},
"targetIndustries": ["electronics assembly", "garment", "food processing", "rubber"]
},
{
"id": "seed-kcn-020",
"name": "KCN Chu Lai Quảng Nam",
"slug": "chu-lai-quang-nam",
"province": "Quảng Nam",
"region": "central",
"status": "operational",
"totalAreaHa": 1550,
"occupancyRate": 55,
"establishedYear": 2003,
"landRentUsdM2Year": 40,
"rbfRentUsdM2Month": 2.8,
"rbwRentUsdM2Month": 2.2,
"connectivity": {
"nearestPort": {"name": "Cảng Kỳ Hà", "distanceKm": 5},
"airport": {"name": "Chu Lai", "distanceKm": 8},
"highway": {"name": "QL 1A", "distanceKm": 3}
},
"incentives": {"specialZone": true},
"targetIndustries": ["automotive", "agriculture machinery", "wood processing", "seafood processing"]
}
]

View File

@@ -0,0 +1,188 @@
{
"version": "ridge-industrial-v1",
"trained_at": "2026-04-18T08:19:02.245595+00:00",
"n_parks_in_source": 20,
"heads": {
"land": {
"target_column": "landRentUsdM2Year",
"n_train": 20,
"alpha": 7.847599703514607,
"mape_loo": 0.1463,
"coverage_80_loo": 0.8,
"q80_log": 0.1883,
"top_coefficients": [
{
"feature": "region_central",
"coef": -0.0873
},
{
"feature": "log_fdi_province",
"coef": 0.0856
},
{
"feature": "occupancy",
"coef": 0.0618
},
{
"feature": "ind_electronics",
"coef": 0.0502
},
{
"feature": "log_dist_airport_km",
"coef": -0.0355
},
{
"feature": "ind_plastics",
"coef": -0.0259
},
{
"feature": "ind_garment",
"coef": 0.0124
},
{
"feature": "region_north",
"coef": -0.0117
}
],
"slices": {
"central": {
"n": 2,
"mape_in_sample": 0.1158,
"median_residual_log": -0.1966
},
"north": {
"n": 7,
"mape_in_sample": 0.0697,
"median_residual_log": -0.0146
},
"south": {
"n": 11,
"mape_in_sample": 0.095,
"median_residual_log": 0.0298
}
}
},
"rbf": {
"target_column": "rbfRentUsdM2Month",
"n_train": 20,
"alpha": 7.847599703514607,
"mape_loo": 0.1118,
"coverage_80_loo": 0.8,
"q80_log": 0.1268,
"top_coefficients": [
{
"feature": "log_fdi_province",
"coef": 0.0582
},
{
"feature": "region_central",
"coef": -0.0529
},
{
"feature": "ind_electronics",
"coef": 0.0348
},
{
"feature": "occupancy",
"coef": 0.0318
},
{
"feature": "log_dist_airport_km",
"coef": -0.0239
},
{
"feature": "ind_plastics",
"coef": -0.0181
},
{
"feature": "log_dist_highway_km",
"coef": -0.0106
},
{
"feature": "ind_food",
"coef": 0.0065
}
],
"slices": {
"central": {
"n": 2,
"mape_in_sample": 0.089,
"median_residual_log": -0.1132
},
"north": {
"n": 7,
"mape_in_sample": 0.0601,
"median_residual_log": -0.0016
},
"south": {
"n": 11,
"mape_in_sample": 0.0758,
"median_residual_log": 0.0139
}
}
},
"rbw": {
"target_column": "rbwRentUsdM2Month",
"n_train": 20,
"alpha": 7.847599703514607,
"mape_loo": 0.1243,
"coverage_80_loo": 0.8,
"q80_log": 0.1214,
"top_coefficients": [
{
"feature": "log_fdi_province",
"coef": 0.0604
},
{
"feature": "region_central",
"coef": -0.0562
},
{
"feature": "ind_electronics",
"coef": 0.0389
},
{
"feature": "occupancy",
"coef": 0.0297
},
{
"feature": "ind_plastics",
"coef": -0.0217
},
{
"feature": "log_dist_airport_km",
"coef": -0.0196
},
{
"feature": "log_dist_highway_km",
"coef": -0.0114
},
{
"feature": "region_north",
"coef": -0.0054
}
],
"slices": {
"central": {
"n": 2,
"mape_in_sample": 0.1026,
"median_residual_log": -0.1232
},
"north": {
"n": 7,
"mape_in_sample": 0.0668,
"median_residual_log": -0.0088
},
"south": {
"n": 11,
"mape_in_sample": 0.0773,
"median_residual_log": 0.0175
}
}
}
},
"warnings": [
"n_train < 30 per head — LOO metrics are noisy; interpret CIs as wide.",
"Targets are log1p-transformed rent; CIs use conformal quantile on log residuals."
]
}

View File

@@ -0,0 +1,458 @@
"""Train the v1 park-level industrial AVM baseline (ridge + monotonic priors).
Context (TEC-2768 / R5.2.1):
The IndustrialPark table ships with ~20 seeded rows carrying three rent
heads: land (usd/m²/year), RBF (ready-built factory, usd/m²/month), and
RBW (ready-built warehouse, usd/m²/month). No IndustrialListing rows are
seeded, so tree-boosted models are not viable at n=20. This script fits a
regularized linear baseline on log-rent with sign-constrained coefficients
that encode domain monotonicity priors (occupancy ↑ rent, distance ↑ rent
↓, etc.). Conformal prediction over LOO residuals gives the 80% CI band.
Usage:
python libs/ai-services/scripts/train_avm_industrial_park.py \
--input libs/ai-services/data/industrial/parks.json \
--out libs/ai-services/models
Produces:
<out>/avm_industrial_park_ridge_v1.pkl — fitted artifact
<out>/avm_industrial_park_ridge_v1.model_card.json — metrics + slices
"""
from __future__ import annotations
import argparse
import json
import math
import os
import pickle
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import numpy as np
from scipy.optimize import nnls
from sklearn.preprocessing import StandardScaler
# ── Constants ──────────────────────────────────────────────────
ARTIFACT_VERSION = "ridge-industrial-v1"
CURRENT_YEAR = 2026
REGION_ORDER = ["south", "north", "central"] # drop-first encoding
TOP_INDUSTRIES = ["electronics", "logistics", "automotive", "food", "garment", "plastics"]
# Province → FDI inflow in million USD (trailing 12m, approximate market data).
PROVINCE_FDI_MUSD: dict[str, float] = {
"tp. hồ chí minh": 5500,
"hà nội": 4200,
"bình dương": 4800,
"đồng nai": 3200,
"bắc ninh": 5800,
"hải phòng": 2800,
"long an": 1500,
"bà rịa - vũng tàu": 1800,
"hải dương": 800,
"hưng yên": 1200,
"bình phước": 400,
"nghệ An": 350,
"nghệ an": 350,
"quảng nam": 500,
"quảng ngãi": 600,
}
DEFAULT_FDI = 500.0
# Feature expected-sign map (+1 rent↑ when feature↑, 1 rent↓ when feature↑).
# Region one-hots stay unsigned (fixed effect).
SIGN_PRIORS: dict[str, int] = {
"occupancy": +1,
"log_area_ha": +1,
"park_age_years": -1,
"log_dist_port_km": -1,
"log_dist_airport_km": -1,
"log_dist_highway_km": -1,
"logistics_connectivity_score": +1,
"log_fdi_province": +1,
"has_special_zone": +1,
"ind_electronics": +1,
"ind_logistics": +1,
"ind_automotive": +1,
"ind_food": 0,
"ind_garment": 0,
"ind_plastics": 0,
}
MONOTONIC_FEATURES = [f for f, s in SIGN_PRIORS.items() if s != 0]
REGION_FEATURES = [f"region_{r}" for r in REGION_ORDER[1:]] # drop south
ALL_FEATURES = list(SIGN_PRIORS.keys()) + REGION_FEATURES
# ── Feature engineering ────────────────────────────────────────
@dataclass
class FeatureSpec:
"""Serializable feature spec so the loader can recreate training features."""
feature_cols: list[str] = field(default_factory=lambda: list(ALL_FEATURES))
region_order: list[str] = field(default_factory=lambda: list(REGION_ORDER))
top_industries: list[str] = field(default_factory=lambda: list(TOP_INDUSTRIES))
province_fdi: dict[str, float] = field(default_factory=lambda: dict(PROVINCE_FDI_MUSD))
default_fdi: float = DEFAULT_FDI
sign_priors: dict[str, int] = field(default_factory=lambda: dict(SIGN_PRIORS))
current_year: int = CURRENT_YEAR
def _connectivity_distance(conn: dict | None, key: str, default: float) -> float:
if not conn or not isinstance(conn, dict):
return default
node = conn.get(key)
if isinstance(node, dict):
dist = node.get("distanceKm") or node.get("km")
if isinstance(dist, (int, float)) and dist >= 0:
return float(dist)
return default
def _logistics_score(dist_port: float, dist_airport: float, dist_highway: float) -> float:
# Inverse-distance composite scaled to [0, 1]. Weights bias toward highway
# proximity which matters most for trucking in VN industrial flows.
def inv(d: float, cap: float) -> float:
return max(0.0, 1.0 - min(d, cap) / cap)
return round(
0.25 * inv(dist_port, 120)
+ 0.20 * inv(dist_airport, 80)
+ 0.55 * inv(dist_highway, 20),
4,
)
def _industry_match(industries: list[str], target: str) -> int:
lowered = [i.lower() for i in industries or []]
return int(any(target in i for i in lowered))
def featureize(row: dict, spec: FeatureSpec) -> dict[str, float]:
"""Turn one park record into the flat feature vector used by the ridge."""
occupancy = row.get("occupancyRate") or 0
if occupancy > 1.5: # seed stores 0-100, plan normalizes to [0,1]
occupancy = occupancy / 100.0
occupancy = min(max(occupancy, 0.0), 1.0)
area_ha = float(row.get("totalAreaHa") or 0.0)
established = row.get("establishedYear") or (spec.current_year - 10)
park_age = max(0, spec.current_year - int(established))
conn = row.get("connectivity") or {}
dist_port = _connectivity_distance(conn, "nearestPort", 60.0)
dist_airport = _connectivity_distance(conn, "airport", 30.0)
dist_highway = _connectivity_distance(conn, "highway", 5.0)
logistics_score = _logistics_score(dist_port, dist_airport, dist_highway)
province = (row.get("province") or "").strip().lower()
fdi = spec.province_fdi.get(province, spec.default_fdi)
incentives = row.get("incentives") or {}
has_special = int(bool(incentives.get("specialZone")))
industries = row.get("targetIndustries") or []
region = str(row.get("region") or "south").lower()
feats = {
"occupancy": occupancy,
"log_area_ha": math.log1p(area_ha),
"park_age_years": float(park_age),
"log_dist_port_km": math.log1p(dist_port),
"log_dist_airport_km": math.log1p(dist_airport),
"log_dist_highway_km": math.log1p(dist_highway),
"logistics_connectivity_score": logistics_score,
"log_fdi_province": math.log1p(fdi),
"has_special_zone": float(has_special),
}
for ind in spec.top_industries:
feats[f"ind_{ind}"] = float(_industry_match(industries, ind))
for r in spec.region_order[1:]:
feats[f"region_{r}"] = float(region == r)
return feats
def build_feature_matrix(rows: list[dict], spec: FeatureSpec) -> tuple[np.ndarray, list[str]]:
mats = [featureize(r, spec) for r in rows]
cols = spec.feature_cols
X = np.array([[m[c] for c in cols] for m in mats], dtype=np.float64)
return X, cols
# ── Sign-constrained ridge ─────────────────────────────────────
def fit_ridge_nn(X: np.ndarray, y: np.ndarray, alpha: float, sign_vec: np.ndarray) -> np.ndarray:
"""Fit `y ≈ X @ β` with ridge penalty α and sign constraints.
sign_vec[i] ∈ {1, 0, +1}. For +1/1 entries, the returned coefficient is
constrained to have that sign. Solved as NNLS on the augmented system:
minimize ‖[X; sqrt(α)*I] β̃ [y; 0]‖² subject to β̃ ≥ 0
with features pre-multiplied by sign_vec (so "1" features become "expect
positive after flipping"). For sign 0 (e.g. neutral industry flags) we keep
the feature unsigned by solving the corresponding coefficient on ±-split
columns.
"""
n, p = X.shape
# Expand each sign==0 column into two columns (positive and negative part)
# so the NNLS solve can recover an unconstrained coefficient as β = β⁺ β⁻.
expand_cols: list[np.ndarray] = []
col_meta: list[tuple[int, int]] = [] # (orig_idx, +1 or -1)
for j in range(p):
if sign_vec[j] == 0:
expand_cols.append(X[:, j])
col_meta.append((j, +1))
expand_cols.append(-X[:, j])
col_meta.append((j, -1))
else:
# Flip so expected sign becomes +, enabling non-negativity constraint.
expand_cols.append(sign_vec[j] * X[:, j])
col_meta.append((j, int(sign_vec[j])))
X_exp = np.stack(expand_cols, axis=1)
# Augment for ridge.
k = X_exp.shape[1]
X_aug = np.vstack([X_exp, math.sqrt(alpha) * np.eye(k)])
y_aug = np.concatenate([y, np.zeros(k)])
beta_exp, _ = nnls(X_aug, y_aug, maxiter=5 * k)
# Collapse expanded coefs back to original column indices.
beta = np.zeros(p)
for col_idx, (orig_j, sgn) in enumerate(col_meta):
if sign_vec[orig_j] == 0:
beta[orig_j] += sgn * beta_exp[col_idx]
else:
# sgn == sign_vec[orig_j]; β was fit on flipped column, so flip back.
beta[orig_j] = sgn * beta_exp[col_idx]
return beta
# ── Model selection + conformal CI ─────────────────────────────
def _pred(X: np.ndarray, beta: np.ndarray, intercept: float) -> np.ndarray:
return X @ beta + intercept
def loo_cv_mape(
X: np.ndarray,
y_log: np.ndarray,
alpha: float,
sign_vec: np.ndarray,
scaler: StandardScaler,
) -> tuple[float, np.ndarray]:
"""Return (MAPE on original rent scale, LOO residual vector in log-space)."""
n = X.shape[0]
residuals_log = np.zeros(n)
preds_rent = np.zeros(n)
for i in range(n):
mask = np.ones(n, dtype=bool)
mask[i] = False
X_train_raw = X[mask]
X_train = scaler.fit_transform(X_train_raw)
y_train = y_log[mask]
intercept = float(np.mean(y_train))
X_cent = X_train
beta = fit_ridge_nn(X_cent, y_train - intercept, alpha, sign_vec)
x_test = scaler.transform(X[i : i + 1])
yhat_log = float(_pred(x_test, beta, intercept)[0])
residuals_log[i] = y_log[i] - yhat_log
preds_rent[i] = math.expm1(yhat_log)
y_true = np.expm1(y_log)
mape = float(np.mean(np.abs(preds_rent - y_true) / np.maximum(y_true, 1e-6)))
return mape, residuals_log
def conformal_coverage(residuals_log: np.ndarray, q: float) -> float:
return float(np.mean(np.abs(residuals_log) <= q))
# ── Training pipeline ──────────────────────────────────────────
def train_head(
rows: list[dict],
target_key: str,
spec: FeatureSpec,
) -> dict[str, Any]:
"""Fit one rent head and return a serializable head dict."""
valid = [r for r in rows if r.get(target_key) is not None]
if len(valid) < 8:
raise ValueError(f"Head '{target_key}': only {len(valid)} non-null rows — too few to train.")
X, cols = build_feature_matrix(valid, spec)
y_raw = np.array([r[target_key] for r in valid], dtype=np.float64)
y_log = np.log1p(y_raw)
sign_vec = np.array([spec.sign_priors.get(c, 0) for c in cols], dtype=np.int8)
# Fit scaler on full (we also refit per-fold in LOO; this one is for final model).
scaler_final = StandardScaler()
scaler_final.fit(X)
alphas = np.logspace(-2, 3, 20)
best = None
for a in alphas:
mape, res = loo_cv_mape(X, y_log, a, sign_vec, StandardScaler())
if best is None or mape < best["mape"]:
best = {"alpha": a, "mape": mape, "residuals_log": res}
assert best is not None
# Refit on full set with chosen alpha.
X_std = scaler_final.transform(X)
intercept = float(np.mean(y_log))
beta = fit_ridge_nn(X_std, y_log - intercept, best["alpha"], sign_vec)
q80 = float(np.quantile(np.abs(best["residuals_log"]), 0.80))
coverage = conformal_coverage(best["residuals_log"], q80)
# Per-region slice metrics.
slices: dict[str, dict[str, float]] = {}
regions = np.array([r.get("region", "south") for r in valid])
preds_rent = np.expm1(X_std @ beta + intercept)
y_rent = np.expm1(y_log)
for region in np.unique(regions):
idx = np.where(regions == region)[0]
if idx.size == 0:
continue
mape_slice = float(
np.mean(np.abs(preds_rent[idx] - y_rent[idx]) / np.maximum(y_rent[idx], 1e-6))
)
slices[region] = {
"n": int(idx.size),
"mape_in_sample": round(mape_slice, 4),
"median_residual_log": round(float(np.median(best["residuals_log"][idx])), 4),
}
return {
"coefficients": beta,
"intercept": intercept,
"scaler": scaler_final,
"alpha": float(best["alpha"]),
"q80_log": q80,
"feature_cols": cols,
"sign_vec": sign_vec,
"n_train": len(valid),
"mape_loo": round(float(best["mape"]), 4),
"coverage_80_loo": round(coverage, 4),
"slices": slices,
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--input",
default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data/industrial/parks.json",
),
)
parser.add_argument(
"--out",
default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"models",
),
)
args = parser.parse_args(argv)
with open(args.input, "r", encoding="utf-8") as f:
rows: list[dict] = json.load(f)
spec = FeatureSpec()
head_specs = {
"land": "landRentUsdM2Year",
"rbf": "rbfRentUsdM2Month",
"rbw": "rbwRentUsdM2Month",
}
heads: dict[str, dict[str, Any]] = {}
card_heads: dict[str, dict[str, Any]] = {}
for head_name, target_key in head_specs.items():
print(f"→ Training head '{head_name}' on target '{target_key}'...")
head = train_head(rows, target_key, spec)
heads[head_name] = head
card_heads[head_name] = {
"target_column": target_key,
"n_train": head["n_train"],
"alpha": head["alpha"],
"mape_loo": head["mape_loo"],
"coverage_80_loo": head["coverage_80_loo"],
"q80_log": round(head["q80_log"], 4),
"top_coefficients": _top_coefs(head),
"slices": head["slices"],
}
print(
f" α={head['alpha']:.4g} MAPE_LOO={head['mape_loo']:.3f}"
f" coverage_80={head['coverage_80_loo']:.3f} n={head['n_train']}"
)
os.makedirs(args.out, exist_ok=True)
pkl_path = os.path.join(args.out, "avm_industrial_park_ridge_v1.pkl")
card_path = os.path.join(args.out, "avm_industrial_park_ridge_v1.model_card.json")
# Serialize to a plain-dict artifact — no trainer class references — so the
# API loader can unpickle without importing this training module.
artifact = {
"version": ARTIFACT_VERSION,
"feature_spec": {
"feature_cols": spec.feature_cols,
"region_order": spec.region_order,
"top_industries": spec.top_industries,
"province_fdi": spec.province_fdi,
"default_fdi": spec.default_fdi,
"sign_priors": spec.sign_priors,
"current_year": spec.current_year,
},
"heads": {
name: {
"coefficients": np.asarray(head["coefficients"], dtype=np.float64),
"intercept": float(head["intercept"]),
"scaler_mean": np.asarray(head["scaler"].mean_, dtype=np.float64),
"scaler_scale": np.asarray(head["scaler"].scale_, dtype=np.float64),
"alpha": head["alpha"],
"q80_log": head["q80_log"],
"feature_cols": head["feature_cols"],
"n_train": head["n_train"],
"mape_loo": head["mape_loo"],
"coverage_80_loo": head["coverage_80_loo"],
}
for name, head in heads.items()
},
"trained_at": datetime.now(timezone.utc).isoformat(),
}
with open(pkl_path, "wb") as f:
pickle.dump(artifact, f)
card = {
"version": ARTIFACT_VERSION,
"trained_at": artifact["trained_at"],
"n_parks_in_source": len(rows),
"heads": card_heads,
"warnings": [
"n_train < 30 per head — LOO metrics are noisy; interpret CIs as wide.",
"Targets are log1p-transformed rent; CIs use conformal quantile on log residuals.",
],
}
with open(card_path, "w", encoding="utf-8") as f:
json.dump(card, f, ensure_ascii=False, indent=2)
print(f"\n✓ Wrote artifact → {pkl_path}")
print(f"✓ Wrote model card → {card_path}")
return 0
def _top_coefs(head: dict[str, Any], k: int = 8) -> list[dict[str, float]]:
beta = head["coefficients"]
cols = head["feature_cols"]
order = np.argsort(-np.abs(beta))[:k]
return [
{"feature": cols[i], "coef": round(float(beta[i]), 4)}
for i in order
if abs(beta[i]) > 1e-6
]
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,11 +1,19 @@
"""Tests for industrial AVM rent estimation endpoint."""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models.avm_industrial import IndustrialAVMRequest
client = TestClient(app)
REPO_ROOT = Path(__file__).resolve().parent.parent
RIDGE_MODEL_DIR = REPO_ROOT / "models"
RIDGE_ARTIFACT = RIDGE_MODEL_DIR / "avm_industrial_park_ridge_v1.pkl"
# ── Minimal valid request payload ───────────────────────────────
_PREDICT_PAYLOAD = {
@@ -178,3 +186,99 @@ def test_predict_industrial_invalid_occupancy():
json={**_PREDICT_PAYLOAD, "park_occupancy_rate": 1.5},
)
assert resp.status_code == 422
# ── Ridge v1 artifact tests (TEC-2768) ───────────────────────────────
_RIDGE_REQ = IndustrialAVMRequest(
province="Bình Dương",
region="south",
park_occupancy_rate=0.85,
park_area_ha=500,
park_age_years=10,
distance_to_port_km=25,
distance_to_airport_km=20,
distance_to_highway_km=2,
property_type="ready_built_factory",
area_m2=5000,
ceiling_height_m=10,
floor_load_ton_m2=3.0,
power_capacity_kva=1500,
building_coverage=0.55,
loading_docks=4,
zoning="general_industrial",
industry_demand_index=0.7,
fdi_province_musd=4800,
labor_cost_province_vnd=8_500_000,
logistics_connectivity_score=0.85,
)
def _fresh_service_with_model_dir(model_dir: Path):
"""Build a fresh service instance pointed at `model_dir`.
Needed because `industrial_avm_service` is a module-level singleton whose
backend is decided at import time.
"""
from app.config import settings
from app.services.avm_industrial_service import IndustrialAVMService
original = settings.model_path
settings.model_path = str(model_dir)
try:
return IndustrialAVMService()
finally:
settings.model_path = original
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
def test_predict_uses_ridge_when_artifact_present():
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
assert svc._backend == "ridge"
assert svc._model_version == "ridge-industrial-v1"
resp = svc.predict(_RIDGE_REQ)
assert resp.model_version == "ridge-industrial-v1"
assert resp.estimated_rent_usd_m2 > 0
assert resp.rent_range_low_usd_m2 <= resp.estimated_rent_usd_m2
assert resp.rent_range_high_usd_m2 >= resp.estimated_rent_usd_m2
# Conformal band must have strictly positive width.
assert resp.rent_range_high_usd_m2 > resp.rent_range_low_usd_m2
# Confidence should match the stored LOO coverage (≥ 0.75 acceptance).
assert resp.confidence >= 0.75
def test_predict_falls_back_to_heuristic_when_artifact_absent(tmp_path: Path):
svc = _fresh_service_with_model_dir(tmp_path) # empty dir → no artifacts
assert svc._backend == "heuristic"
resp = svc.predict(_RIDGE_REQ)
assert resp.model_version == "heuristic-v1"
assert resp.estimated_rent_usd_m2 > 0
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
def test_ridge_monotonic_occupancy():
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
low = svc.predict(_RIDGE_REQ.model_copy(update={"park_occupancy_rate": 0.30}))
high = svc.predict(_RIDGE_REQ.model_copy(update={"park_occupancy_rate": 0.95}))
assert high.estimated_rent_usd_m2 >= low.estimated_rent_usd_m2
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
def test_ridge_land_head_conversion():
"""industrial_land requests must convert annual → monthly USD/m²."""
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
resp = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "industrial_land"}))
# annual_rent_usd_m2 ≈ 12 × estimated_rent_usd_m2 (with rounding tolerance)
assert resp.estimated_rent_usd_m2 > 0
assert abs(resp.annual_rent_usd_m2 - resp.estimated_rent_usd_m2 * 12) < 0.5
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
def test_ridge_warehouse_head_different_from_factory():
"""Warehouse and factory requests must route to different ridge heads."""
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
rbf = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "ready_built_factory"}))
rbw = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "warehouse"}))
# Training data consistently shows RBF > RBW rents — heads should reflect that.
assert rbf.estimated_rent_usd_m2 != rbw.estimated_rent_usd_m2

View File

@@ -0,0 +1,504 @@
/**
* Integration test: verifies all MCP servers register correctly in McpRegistryService
* and each tool is callable with valid response schemas.
*
* External HTTP calls (AI service, NestJS API) are mocked via globalThis.fetch.
* Typesense is mocked at the client level.
*/
import type { Client as TypesenseClient } from 'typesense';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { createIndustrialParksServer } from '../industrial-parks/industrial-parks.server';
import { createMarketAnalyticsServer } from '../market-analytics/market-analytics.server';
import { createPropertySearchServer } from '../property-search/property-search.server';
import { createReportsServer } from '../reports/reports.server';
import { createValuationServer } from '../valuation/valuation.server';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ToolResult = {
content: { type: string; text: string }[];
isError?: boolean;
};
// ---------------------------------------------------------------------------
// Mocks — Typesense client
// ---------------------------------------------------------------------------
function createMockTypesenseClient(defaultHits: unknown[] = []) {
const search = vi.fn().mockResolvedValue({
hits: defaultHits.map((d) => ({ document: d })),
found: defaultHits.length,
search_time_ms: 2,
});
return {
collections: vi.fn().mockReturnValue({
documents: vi.fn().mockReturnValue({ search }),
}),
_search: search,
};
}
// ---------------------------------------------------------------------------
// Mocks — fetch responses for each backend
// ---------------------------------------------------------------------------
const MOCK_RESPONSES: Record<string, unknown> = {
'/industrial/analyze-location': {
overall_score: 8.2,
connectivity: {
nearest_port: { name: 'Cảng Cát Lái', distanceKm: 22 },
nearest_airport: { name: 'Tân Sơn Nhất', distanceKm: 28 },
nearest_highway: { name: 'QL1A', distanceKm: 1.5 },
},
infrastructure: {
power_availability: '110kV on-site',
water_supply: 'Municipal',
wastewater_treatment: 'Central WWTP',
telecom: 'Fiber optic',
},
labor_market: {
worker_pool_radius_30km: 450000,
average_wage_usd: 290,
nearby_universities: ['ĐH Bình Dương'],
},
incentives: ['CIT exemption 4 years'],
risks: ['Flooding risk'],
},
'/industrial/estimate-rent': {
estimated_rent_usd_m2: 4.5,
pricing_unit: 'USD/m²/month',
total_monthly_usd: 45000,
total_lease_usd: 5400000,
management_fee_usd_m2: 0.6,
deposit_months: 3,
market_comparison: {
province_low: 3.0,
province_high: 7.0,
province_avg: 4.8,
},
breakdown: [
{ item: 'Base rent', amount: 38000 },
{ item: 'Management fee', amount: 6000 },
],
},
'/reports/generate': {
report_id: 'rpt-int-001',
report_type: 'market_overview',
title: 'Báo cáo thị trường Q7',
location: 'Quận 7, Hồ Chí Minh',
generated_at: '2026-04-16T10:00:00Z',
summary: 'Thị trường ổn định',
sections: [{ title: 'Tổng quan', content: '...', charts: [] }],
key_metrics: { avgPriceVND: 4_500_000_000 },
},
'/reports/macro-data': {
province: 'Bình Dương',
data: {
gdp: [{ year: 2024, value: 20.1, unit: 'billion USD', yoy_change: 8.6 }],
},
highlights: ['GDP above national average'],
},
};
function mockFetchForUrl(url: string): Response {
for (const [path, body] of Object.entries(MOCK_RESPONSES)) {
if (url.includes(path)) {
return {
ok: true,
status: 200,
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
}
}
return {
ok: false,
status: 404,
text: async () => 'Not found',
} as unknown as Response;
}
// ---------------------------------------------------------------------------
// Industrial park sample document (for Typesense search results)
// ---------------------------------------------------------------------------
const SAMPLE_PARK = {
parkId: 'park-int-001',
name: 'KCN VSIP II-A',
nameEn: 'VSIP II-A Industrial Park',
developer: 'VSIP Group',
province: 'Bình Dương',
region: 'south',
status: 'operational',
totalAreaHa: 345,
remainingAreaHa: 62,
occupancyRate: 82,
landRentUsdM2Year: 90,
rbfRentUsdM2Month: 4.8,
rbwRentUsdM2Month: 3.5,
targetIndustries: ['electronics', 'automotive'],
tenantCount: 85,
};
// ---------------------------------------------------------------------------
// Helper: extract tool handler from McpServer internal state
// ---------------------------------------------------------------------------
function getToolHandler(
server: unknown,
name: string,
): (params: unknown) => Promise<ToolResult> {
const tools = (
server as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> }
)._registeredTools;
const entry = tools[name];
if (!entry) {
throw new Error(`Tool "${name}" not registered. Available: ${Object.keys(tools).join(', ')}`);
}
return entry.handler;
}
function parseToolResult(result: ToolResult): Record<string, unknown> {
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
return JSON.parse(result.content[0].text) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------
describe('MCP Integration: all servers and tools end-to-end', () => {
const typesenseClient = createMockTypesenseClient([SAMPLE_PARK]);
let industrialServer: ReturnType<typeof createIndustrialParksServer>;
let reportsServer: ReturnType<typeof createReportsServer>;
const fetchSpy = vi.spyOn(globalThis, 'fetch');
beforeAll(() => {
fetchSpy.mockImplementation(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return mockFetchForUrl(url);
});
industrialServer = createIndustrialParksServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'industrial_parks',
aiServiceBaseUrl: 'http://ai-service:8000',
});
reportsServer = createReportsServer({
apiBaseUrl: 'http://api:3001/api/v1',
});
});
afterAll(() => {
fetchSpy.mockRestore();
});
// -----------------------------------------------------------------------
// 1. Server factory tests — all 5 factories produce valid McpServer instances
// -----------------------------------------------------------------------
describe('server factories', () => {
it('creates all 5 server instances without errors', () => {
expect(industrialServer).toBeDefined();
expect(reportsServer).toBeDefined();
const propertySearch = createPropertySearchServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'listings',
});
expect(propertySearch).toBeDefined();
const marketAnalytics = createMarketAnalyticsServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'listings',
});
expect(marketAnalytics).toBeDefined();
const valuation = createValuationServer({
aiServiceBaseUrl: 'http://ai-service:8000',
});
expect(valuation).toBeDefined();
});
});
// -----------------------------------------------------------------------
// 2. Industrial parks server — 3 tools
// -----------------------------------------------------------------------
describe('industrial-parks server', () => {
it('search_industrial_parks: returns structured results from Typesense', async () => {
const handler = getToolHandler(industrialServer, 'search_industrial_parks');
const result = await handler({
query: 'VSIP Bình Dương',
page: 1,
perPage: 20,
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result);
// Schema validation
expect(data).toHaveProperty('totalFound');
expect(data).toHaveProperty('page');
expect(data).toHaveProperty('perPage');
expect(data).toHaveProperty('searchTimeMs');
expect(data).toHaveProperty('results');
expect(typeof data.totalFound).toBe('number');
const results = data.results as Record<string, unknown>[];
expect(results.length).toBeGreaterThan(0);
// Validate result item schema
const item = results[0];
expect(item).toHaveProperty('parkId');
expect(item).toHaveProperty('name');
expect(item).toHaveProperty('developer');
expect(item).toHaveProperty('province');
expect(item).toHaveProperty('region');
expect(item).toHaveProperty('status');
expect(item).toHaveProperty('totalAreaHa');
expect(item).toHaveProperty('remainingAreaHa');
expect(item).toHaveProperty('occupancyRate');
expect(item).toHaveProperty('landRentUsdM2Year');
expect(item).toHaveProperty('targetIndustries');
expect(item).toHaveProperty('tenantCount');
});
it('analyze_industrial_location: calls AI service and returns analysis schema', async () => {
const handler = getToolHandler(industrialServer, 'analyze_industrial_location');
const result = await handler({
latitude: 11.05,
longitude: 106.65,
targetIndustry: 'electronics',
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result);
// Schema validation
expect(data).toHaveProperty('overallScore');
expect(data).toHaveProperty('connectivity');
expect(data).toHaveProperty('infrastructure');
expect(data).toHaveProperty('laborMarket');
expect(data).toHaveProperty('incentives');
expect(data).toHaveProperty('risks');
expect(typeof data.overallScore).toBe('number');
const connectivity = data.connectivity as Record<string, unknown>;
expect(connectivity).toHaveProperty('nearestPort');
expect(connectivity).toHaveProperty('nearestAirport');
// Verify correct URL was called
expect(fetchSpy).toHaveBeenCalledWith(
'http://ai-service:8000/industrial/analyze-location',
expect.objectContaining({ method: 'POST' }),
);
});
it('estimate_industrial_rent: calls AI service and returns rent estimate schema', async () => {
const handler = getToolHandler(industrialServer, 'estimate_industrial_rent');
const result = await handler({
province: 'Bình Dương',
propertyType: 'ready_built_factory',
areaM2: 10000,
leaseDurationYears: 10,
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result);
// Schema validation
expect(data).toHaveProperty('estimatedRentUsdM2');
expect(data).toHaveProperty('pricingUnit');
expect(data).toHaveProperty('totalMonthlyUsd');
expect(data).toHaveProperty('totalLeaseUsd');
expect(data).toHaveProperty('managementFeeUsdM2');
expect(data).toHaveProperty('depositMonths');
expect(data).toHaveProperty('marketComparison');
expect(data).toHaveProperty('breakdown');
expect(data).toHaveProperty('input');
expect(typeof data.estimatedRentUsdM2).toBe('number');
const mc = data.marketComparison as Record<string, unknown>;
expect(mc).toHaveProperty('provinceLow');
expect(mc).toHaveProperty('provinceHigh');
expect(mc).toHaveProperty('provinceAvg');
// Verify correct URL was called
expect(fetchSpy).toHaveBeenCalledWith(
'http://ai-service:8000/industrial/estimate-rent',
expect.objectContaining({ method: 'POST' }),
);
});
});
// -----------------------------------------------------------------------
// 3. Reports server — 2 tools
// -----------------------------------------------------------------------
describe('reports server', () => {
it('generate_report: calls NestJS API and returns report schema', async () => {
const handler = getToolHandler(reportsServer, 'generate_report');
const result = await handler({
reportType: 'market_overview',
location: 'Quận 7, Hồ Chí Minh',
period: '1y',
includeForecasts: false,
includeMacro: false,
language: 'vi',
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result);
// Schema validation
expect(data).toHaveProperty('reportId');
expect(data).toHaveProperty('reportType');
expect(data).toHaveProperty('title');
expect(data).toHaveProperty('location');
expect(data).toHaveProperty('generatedAt');
expect(data).toHaveProperty('summary');
expect(data).toHaveProperty('sections');
expect(data).toHaveProperty('keyMetrics');
expect(typeof data.reportId).toBe('string');
expect(Array.isArray(data.sections)).toBe(true);
// Verify correct URL was called (NestJS API, not AI service)
expect(fetchSpy).toHaveBeenCalledWith(
'http://api:3001/api/v1/reports/generate',
expect.objectContaining({ method: 'POST' }),
);
});
it('get_macro_data: calls NestJS API with GET and returns macro data schema', async () => {
const handler = getToolHandler(reportsServer, 'get_macro_data');
const result = await handler({
province: 'Bình Dương',
categories: ['gdp'],
fromYear: 2024,
toYear: 2024,
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result);
// Schema validation
expect(data).toHaveProperty('province');
expect(data).toHaveProperty('period');
expect(data).toHaveProperty('data');
expect(data).toHaveProperty('highlights');
expect(data.province).toBe('Bình Dương');
const period = data.period as Record<string, number>;
expect(period.from).toBe(2024);
expect(period.to).toBe(2024);
const macroData = data.data as Record<string, unknown[]>;
expect(macroData).toHaveProperty('gdp');
expect(macroData.gdp).toHaveLength(1);
const gdpPoint = macroData.gdp[0] as Record<string, unknown>;
expect(gdpPoint).toHaveProperty('year');
expect(gdpPoint).toHaveProperty('value');
expect(gdpPoint).toHaveProperty('unit');
expect(gdpPoint).toHaveProperty('yoyChange');
// Verify it used GET (not POST)
const macroCall = fetchSpy.mock.calls.find(
(call) => (call[0] as string).includes('/reports/macro-data'),
);
expect(macroCall).toBeDefined();
expect((macroCall![1] as RequestInit).method).toBe('GET');
});
});
// -----------------------------------------------------------------------
// 4. Env var routing: industrial tools → AI_SERVICE_URL, reports → API_BASE_URL
// -----------------------------------------------------------------------
describe('env var routing', () => {
it('industrial tools call aiServiceBaseUrl (AI_SERVICE_URL)', async () => {
const analyzeCall = fetchSpy.mock.calls.find(
(call) => (call[0] as string).includes('/industrial/analyze-location'),
);
expect(analyzeCall).toBeDefined();
expect((analyzeCall![0] as string).startsWith('http://ai-service:8000')).toBe(true);
const rentCall = fetchSpy.mock.calls.find(
(call) => (call[0] as string).includes('/industrial/estimate-rent'),
);
expect(rentCall).toBeDefined();
expect((rentCall![0] as string).startsWith('http://ai-service:8000')).toBe(true);
});
it('report tools call apiBaseUrl (API_BASE_URL)', async () => {
const reportCall = fetchSpy.mock.calls.find(
(call) => (call[0] as string).includes('/reports/generate'),
);
expect(reportCall).toBeDefined();
expect((reportCall![0] as string).startsWith('http://api:3001')).toBe(true);
const macroCall = fetchSpy.mock.calls.find(
(call) => (call[0] as string).includes('/reports/macro-data'),
);
expect(macroCall).toBeDefined();
expect((macroCall![0] as string).startsWith('http://api:3001')).toBe(true);
});
});
// -----------------------------------------------------------------------
// 5. Registry simulation — verify all servers can be registered
// -----------------------------------------------------------------------
describe('registry integration', () => {
it('McpRegistryService registers industrial-parks and reports servers', async () => {
// Simulate what McpRegistryService.onModuleInit does
const servers = new Map<string, unknown>();
servers.set(
'property-search',
createPropertySearchServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'listings',
}),
);
servers.set(
'market-analytics',
createMarketAnalyticsServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'listings',
}),
);
servers.set(
'valuation',
createValuationServer({ aiServiceBaseUrl: 'http://ai-service:8000' }),
);
servers.set(
'industrial-parks',
createIndustrialParksServer({
typesenseClient: typesenseClient as unknown as TypesenseClient,
collectionName: 'industrial_parks',
aiServiceBaseUrl: 'http://ai-service:8000',
}),
);
servers.set(
'reports',
createReportsServer({ apiBaseUrl: 'http://api:3001/api/v1' }),
);
// All 5 servers should be registered
expect(servers.size).toBe(5);
expect(Array.from(servers.keys()).sort()).toEqual([
'industrial-parks',
'market-analytics',
'property-search',
'reports',
'valuation',
]);
// Each server should be a valid McpServer instance
for (const [name, server] of servers) {
expect(server, `Server "${name}" should be defined`).toBeDefined();
}
});
});
});

View File

@@ -0,0 +1,146 @@
import type { PrismaService } from '@modules/shared';
import { AnalyzeIndustrialLocationHandler } from '../queries/analyze-industrial-location/analyze-industrial-location.handler';
import { AnalyzeIndustrialLocationQuery } from '../queries/analyze-industrial-location/analyze-industrial-location.query';
describe('AnalyzeIndustrialLocationHandler', () => {
let handler: AnalyzeIndustrialLocationHandler;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
const nearbyParks = [
{
id: 'park-1',
name: 'KCN Mỹ Phước',
province: 'Bình Dương',
region: 'SOUTH',
distanceKm: 2.5,
occupancyRate: 85,
landRentUsdM2Year: 120,
infrastructure: {
electricity: '110kV dedicated',
water: 'Industrial water plant',
wastewater: 'Central treatment',
telecom: 'Fiber optic',
},
connectivity: {
nearestPort: { name: 'Cát Lái Port', distanceKm: 35 },
airport: { name: 'Tân Sơn Nhất', distanceKm: 25 },
highway: { name: 'QL13', distanceKm: 1 },
},
incentives: {
taxHoliday: '4 years',
importDuty: 'Exempted for raw materials',
landRentReduction: '50% first 5 years',
},
targetIndustries: ['Electronics', 'Automotive'],
},
{
id: 'park-2',
name: 'KCN Bàu Bàng',
province: 'Bình Dương',
region: 'SOUTH',
distanceKm: 8.3,
occupancyRate: 60,
landRentUsdM2Year: 100,
infrastructure: null,
connectivity: null,
incentives: null,
targetIndustries: ['Logistics'],
},
];
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn().mockResolvedValue(nearbyParks) };
handler = new AnalyzeIndustrialLocationHandler(mockPrisma as unknown as PrismaService);
});
it('returns analysis with overall score', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.overall_score).toBeGreaterThanOrEqual(0);
expect(result.overall_score).toBeLessThanOrEqual(100);
});
it('returns nearby parks sorted by distance', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.nearby_parks).toHaveLength(2);
expect(result.nearby_parks[0]!.name).toBe('KCN Mỹ Phước');
expect(result.nearby_parks[0]!.distanceKm).toBe(2.5);
});
it('extracts connectivity from nearest park', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.connectivity.nearest_port).toEqual({ name: 'Cát Lái Port', distanceKm: 35 });
expect(result.connectivity.nearest_airport).toEqual({ name: 'Tân Sơn Nhất', distanceKm: 25 });
expect(result.connectivity.nearest_highway).toEqual({ name: 'QL13', distanceKm: 1 });
});
it('extracts infrastructure from nearest park', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.infrastructure.power_availability).toBe('110kV dedicated');
expect(result.infrastructure.water_supply).toBe('Industrial water plant');
expect(result.infrastructure.wastewater_treatment).toBe('Central treatment');
expect(result.infrastructure.telecom).toBe('Fiber optic');
});
it('gathers incentives from target park', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.incentives.length).toBeGreaterThan(0);
expect(result.incentives.some((i) => i.includes('Tax holiday'))).toBe(true);
});
it('returns labor market estimates for SOUTH region', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.labor_market.worker_pool_radius_30km).toBe(500_000);
expect(result.labor_market.average_wage_usd).toBe(350);
expect(result.labor_market.nearby_universities.length).toBeGreaterThan(0);
});
it('matches specific park when parkName provided', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67, 'Bàu Bàng');
const result = await handler.execute(query);
expect(result.connectivity).toEqual({});
expect(result.infrastructure).toEqual({});
});
it('adds risk when target industry not found in nearby parks', async () => {
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67, undefined, 'Pharmaceuticals');
const result = await handler.execute(query);
expect(result.risks.some((r) => r.includes('Pharmaceuticals'))).toBe(true);
});
it('handles empty nearby parks gracefully', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.nearby_parks).toHaveLength(0);
expect(result.risks.some((r) => r.includes('No industrial parks'))).toBe(true);
expect(result.overall_score).toBeLessThan(70);
});
it('adds high occupancy risk when avg > 90%', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ ...nearbyParks[0], occupancyRate: 95 },
{ ...nearbyParks[1], occupancyRate: 92 },
]);
const query = new AnalyzeIndustrialLocationQuery(11.05, 106.67);
const result = await handler.execute(query);
expect(result.risks.some((r) => r.includes('High area occupancy'))).toBe(true);
});
});

View File

@@ -0,0 +1,113 @@
import { NotFoundException } from '@modules/shared';
import type { IIndustrialListingRepository } from '../../domain/repositories/industrial-listing.repository';
import type { IIndustrialParkRepository } from '../../domain/repositories/industrial-park.repository';
import type { TypesenseIndustrialService } from '../../infrastructure/services/typesense-industrial.service';
import { CreateIndustrialListingHandler } from '../commands/create-industrial-listing/create-industrial-listing.handler';
import { CreateIndustrialListingCommand } from '../commands/create-industrial-listing/create-industrial-listing.command';
describe('CreateIndustrialListingHandler', () => {
let handler: CreateIndustrialListingHandler;
let mockRepo: { [K in keyof IIndustrialListingRepository]: ReturnType<typeof vi.fn> };
let mockParkRepo: { [K in keyof IIndustrialParkRepository]: ReturnType<typeof vi.fn> };
let mockTypesense: Partial<{ [K in keyof TypesenseIndustrialService]: ReturnType<typeof vi.fn> }>;
const baseCmd = new CreateIndustrialListingCommand(
'park-1',
'seller-1',
null,
'READY_BUILT_FACTORY',
'FACTORY_LEASE',
'Nhà xưởng 2000m² KCN Bình Dương',
'Nhà xưởng mới xây, sẵn sàng sử dụng',
2000,
12,
5,
null,
4,
null,
false,
true,
200,
5.5,
'USD/m²/month',
null,
0.8,
3,
5,
20,
null,
new Date('2025-01-01'),
500,
100,
null,
);
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findDetailById: vi.fn(),
save: vi.fn().mockResolvedValue(undefined),
update: vi.fn(),
search: vi.fn(),
};
mockParkRepo = {
findById: vi.fn(),
findBySlug: vi.fn(),
findDetailBySlug: vi.fn(),
findDetailById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
compareParks: vi.fn(),
getStats: vi.fn(),
getMarketData: vi.fn(),
};
mockTypesense = {
indexListing: vi.fn().mockResolvedValue(undefined),
indexPark: vi.fn(),
deleteListing: vi.fn(),
};
handler = new CreateIndustrialListingHandler(
mockRepo as any,
mockParkRepo as any,
mockTypesense as any,
);
});
it('creates a listing when park exists', async () => {
mockParkRepo.findById.mockResolvedValue({ id: 'park-1', name: 'Test Park' });
const result = await handler.execute(baseCmd);
expect(result).toHaveProperty('id');
expect(mockParkRepo.findById).toHaveBeenCalledWith('park-1');
expect(mockRepo.save).toHaveBeenCalledTimes(1);
expect(mockTypesense.indexListing).toHaveBeenCalledWith(result.id);
});
it('throws NotFoundException when park does not exist', async () => {
mockParkRepo.findById.mockResolvedValue(null);
await expect(handler.execute(baseCmd)).rejects.toThrow(NotFoundException);
expect(mockRepo.save).not.toHaveBeenCalled();
});
it('creates listing with DRAFT status', async () => {
mockParkRepo.findById.mockResolvedValue({ id: 'park-1' });
await handler.execute(baseCmd);
const savedEntity = mockRepo.save.mock.calls[0]![0];
expect(savedEntity.status).toBe('DRAFT');
});
it('initializes viewCount and inquiryCount to zero', async () => {
mockParkRepo.findById.mockResolvedValue({ id: 'park-1' });
await handler.execute(baseCmd);
const savedEntity = mockRepo.save.mock.calls[0]![0];
expect(savedEntity.viewCount).toBe(0);
expect(savedEntity.inquiryCount).toBe(0);
});
});

View File

@@ -0,0 +1,105 @@
import { ConflictException } from '@modules/shared';
import type { IIndustrialParkRepository } from '../../domain/repositories/industrial-park.repository';
import type { TypesenseIndustrialService } from '../../infrastructure/services/typesense-industrial.service';
import { CreateIndustrialParkHandler } from '../commands/create-industrial-park/create-industrial-park.handler';
import { CreateIndustrialParkCommand } from '../commands/create-industrial-park/create-industrial-park.command';
describe('CreateIndustrialParkHandler', () => {
let handler: CreateIndustrialParkHandler;
let mockRepo: { [K in keyof IIndustrialParkRepository]: ReturnType<typeof vi.fn> };
let mockTypesense: { [K in keyof TypesenseIndustrialService]: ReturnType<typeof vi.fn> };
const baseCmd = new CreateIndustrialParkCommand(
'KCN Bình Dương 3',
'Binh Duong 3 IP',
'kcn-binh-duong-3',
'Becamex IDC',
null,
'OPERATIONAL',
11.05,
106.67,
'123 Đại lộ Bình Dương',
'Thủ Dầu Một',
'Bình Dương',
'SOUTH',
500,
400,
72,
112,
45,
2005,
120,
5.5,
4.8,
0.8,
null,
null,
null,
['Electronics', 'Automotive'],
'Khu công nghiệp Bình Dương 3',
'Binh Duong 3 Industrial Park',
);
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findBySlug: vi.fn(),
findDetailBySlug: vi.fn(),
findDetailById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
compareParks: vi.fn(),
getStats: vi.fn(),
getMarketData: vi.fn(),
};
mockTypesense = {
onModuleInit: vi.fn(),
syncParks: vi.fn(),
syncListings: vi.fn(),
indexPark: vi.fn().mockResolvedValue(undefined),
indexListing: vi.fn(),
deleteListing: vi.fn(),
} as any;
handler = new CreateIndustrialParkHandler(mockRepo as any, mockTypesense as any);
});
it('creates a park and returns its id', async () => {
mockRepo.findBySlug.mockResolvedValue(null);
mockRepo.save.mockResolvedValue(undefined);
const result = await handler.execute(baseCmd);
expect(result).toHaveProperty('id');
expect(typeof result.id).toBe('string');
expect(mockRepo.findBySlug).toHaveBeenCalledWith('kcn-binh-duong-3');
expect(mockRepo.save).toHaveBeenCalledTimes(1);
expect(mockTypesense.indexPark).toHaveBeenCalledWith(result.id);
});
it('throws ConflictException when slug already exists', async () => {
mockRepo.findBySlug.mockResolvedValue({ id: 'existing' });
await expect(handler.execute(baseCmd)).rejects.toThrow(ConflictException);
expect(mockRepo.save).not.toHaveBeenCalled();
});
it('sets isVerified to false on new parks', async () => {
mockRepo.findBySlug.mockResolvedValue(null);
mockRepo.save.mockResolvedValue(undefined);
await handler.execute(baseCmd);
const savedEntity = mockRepo.save.mock.calls[0]![0];
expect(savedEntity.isVerified).toBe(false);
});
it('does not fail if Typesense indexing rejects', async () => {
mockRepo.findBySlug.mockResolvedValue(null);
mockRepo.save.mockResolvedValue(undefined);
mockTypesense.indexPark.mockRejectedValue(new Error('Typesense down'));
const result = await handler.execute(baseCmd);
expect(result).toHaveProperty('id');
});
});

View File

@@ -0,0 +1,65 @@
import { NotFoundException } from '@modules/shared';
import type { IIndustrialListingRepository } from '../../domain/repositories/industrial-listing.repository';
import type { TypesenseIndustrialService } from '../../infrastructure/services/typesense-industrial.service';
import { DeleteIndustrialListingHandler } from '../commands/delete-industrial-listing/delete-industrial-listing.handler';
import { DeleteIndustrialListingCommand } from '../commands/delete-industrial-listing/delete-industrial-listing.command';
describe('DeleteIndustrialListingHandler', () => {
let handler: DeleteIndustrialListingHandler;
let mockRepo: { [K in keyof IIndustrialListingRepository]: ReturnType<typeof vi.fn> };
let mockTypesense: Partial<{ [K in keyof TypesenseIndustrialService]: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findDetailById: vi.fn(),
save: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
search: vi.fn(),
};
mockTypesense = {
deleteListing: vi.fn().mockResolvedValue(undefined),
indexListing: vi.fn(),
indexPark: vi.fn(),
};
handler = new DeleteIndustrialListingHandler(mockRepo as any, mockTypesense as any);
});
it('soft-deletes an existing listing', async () => {
const mockEntity = {
id: 'listing-1',
status: 'ACTIVE',
softDelete: vi.fn(),
};
mockRepo.findById.mockResolvedValue(mockEntity);
await handler.execute(new DeleteIndustrialListingCommand('listing-1'));
expect(mockEntity.softDelete).toHaveBeenCalled();
expect(mockRepo.update).toHaveBeenCalledWith(mockEntity);
expect(mockTypesense.deleteListing).toHaveBeenCalledWith('listing-1');
});
it('throws NotFoundException when listing does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(
handler.execute(new DeleteIndustrialListingCommand('nonexistent')),
).rejects.toThrow(NotFoundException);
expect(mockRepo.update).not.toHaveBeenCalled();
});
it('does not fail if Typesense delete rejects', async () => {
const mockEntity = {
id: 'listing-1',
status: 'ACTIVE',
softDelete: vi.fn(),
};
mockRepo.findById.mockResolvedValue(mockEntity);
mockTypesense.deleteListing!.mockRejectedValue(new Error('Typesense down'));
await expect(
handler.execute(new DeleteIndustrialListingCommand('listing-1')),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,146 @@
import type { PrismaService } from '@modules/shared';
import { EstimateIndustrialRentHandler } from '../queries/estimate-industrial-rent/estimate-industrial-rent.handler';
import { EstimateIndustrialRentQuery } from '../queries/estimate-industrial-rent/estimate-industrial-rent.query';
describe('EstimateIndustrialRentHandler', () => {
let handler: EstimateIndustrialRentHandler;
let mockPrisma: { industrialPark: { findMany: ReturnType<typeof vi.fn> } };
const sampleParks = [
{
name: 'KCN Mỹ Phước',
landRentUsdM2Year: 100,
rbfRentUsdM2Month: 5.0,
rbwRentUsdM2Month: 4.5,
managementFeeUsd: 0.7,
occupancyRate: 85,
},
{
name: 'KCN Bàu Bàng',
landRentUsdM2Year: 130,
rbfRentUsdM2Month: 6.0,
rbwRentUsdM2Month: 5.0,
managementFeeUsd: 0.9,
occupancyRate: 70,
},
];
beforeEach(() => {
mockPrisma = {
industrialPark: { findMany: vi.fn().mockResolvedValue(sampleParks) },
};
handler = new EstimateIndustrialRentHandler(mockPrisma as unknown as PrismaService);
});
it('returns rent estimate for industrial land', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 5000, 10);
const result = await handler.execute(query);
expect(result.pricing_unit).toBe('USD/m²/year');
expect(result.estimated_rent_usd_m2).toBeGreaterThan(0);
expect(result.total_monthly_usd).toBeGreaterThan(0);
expect(result.total_lease_usd).toBeGreaterThan(0);
expect(result.breakdown.length).toBeGreaterThanOrEqual(1);
});
it('returns rent estimate for ready-built factory', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'ready_built_factory', 2000, 5);
const result = await handler.execute(query);
expect(result.pricing_unit).toBe('USD/m²/month');
expect(result.estimated_rent_usd_m2).toBeGreaterThan(0);
});
it('applies crane surcharge (+8%)', async () => {
const baseQuery = new EstimateIndustrialRentQuery('Bình Dương', 'ready_built_factory', 2000, 5);
const craneQuery = new EstimateIndustrialRentQuery('Bình Dương', 'ready_built_factory', 2000, 5, null, true);
const baseResult = await handler.execute(baseQuery);
const craneResult = await handler.execute(craneQuery);
expect(craneResult.estimated_rent_usd_m2).toBeGreaterThan(baseResult.estimated_rent_usd_m2);
const craneLine = craneResult.breakdown.find((b) => b.item.includes('crane'));
expect(craneLine).toBeDefined();
});
it('applies high power surcharge for >500 kVA', async () => {
const baseQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const powerQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5, null, false, 1000);
const baseResult = await handler.execute(baseQuery);
const powerResult = await handler.execute(powerQuery);
expect(powerResult.estimated_rent_usd_m2).toBeGreaterThan(baseResult.estimated_rent_usd_m2);
});
it('applies wastewater surcharge (+3%)', async () => {
const baseQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const wwQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5, null, false, null, true);
const baseResult = await handler.execute(baseQuery);
const wwResult = await handler.execute(wwQuery);
expect(wwResult.estimated_rent_usd_m2).toBeGreaterThan(baseResult.estimated_rent_usd_m2);
});
it('applies long-term lease discount (>=20yr: -10%)', async () => {
const shortQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const longQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 20);
const shortResult = await handler.execute(shortQuery);
const longResult = await handler.execute(longQuery);
expect(longResult.estimated_rent_usd_m2).toBeLessThan(shortResult.estimated_rent_usd_m2);
const discountLine = longResult.breakdown.find((b) => b.item.includes('≥20yr'));
expect(discountLine).toBeDefined();
});
it('applies large area discount (>=10,000m²: -7%)', async () => {
const smallQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const largeQuery = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 10000, 5);
const smallResult = await handler.execute(smallQuery);
const largeResult = await handler.execute(largeQuery);
expect(largeResult.estimated_rent_usd_m2).toBeLessThan(smallResult.estimated_rent_usd_m2);
});
it('uses national average when no province data available', async () => {
mockPrisma.industrialPark.findMany.mockResolvedValue([]);
const query = new EstimateIndustrialRentQuery('Unknown Province', 'industrial_land', 2000, 5);
const result = await handler.execute(query);
expect(result.estimated_rent_usd_m2).toBeGreaterThan(0);
expect(result.market_comparison.province_avg).toBeNull();
});
it('uses specific park rent when parkName matches', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5, 'Mỹ Phước');
const result = await handler.execute(query);
expect(result.estimated_rent_usd_m2).toBeGreaterThan(0);
expect(result.breakdown[0]!.amount).toBe(100);
});
it('returns deposit_months=6 for leases >= 10 years', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 15);
const result = await handler.execute(query);
expect(result.deposit_months).toBe(6);
});
it('returns deposit_months=3 for leases < 10 years', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const result = await handler.execute(query);
expect(result.deposit_months).toBe(3);
});
it('includes market comparison with province low/high/avg', async () => {
const query = new EstimateIndustrialRentQuery('Bình Dương', 'industrial_land', 2000, 5);
const result = await handler.execute(query);
expect(result.market_comparison.province_low).toBe(100);
expect(result.market_comparison.province_high).toBe(130);
expect(result.market_comparison.province_avg).toBeCloseTo(115, 0);
});
});

View File

@@ -0,0 +1,172 @@
import { ValidationException } from '@modules/shared';
import type { IIndustrialParkRepository, IndustrialParkDetailData } from '../../domain/repositories/industrial-park.repository';
import { GetIndustrialParkHandler } from '../queries/get-industrial-park/get-industrial-park.handler';
import { GetIndustrialParkQuery } from '../queries/get-industrial-park/get-industrial-park.query';
import { ListIndustrialParksHandler } from '../queries/list-industrial-parks/list-industrial-parks.handler';
import { ListIndustrialParksQuery } from '../queries/list-industrial-parks/list-industrial-parks.query';
import { CompareIndustrialParksHandler } from '../queries/compare-industrial-parks/compare-industrial-parks.handler';
import { CompareIndustrialParksQuery } from '../queries/compare-industrial-parks/compare-industrial-parks.query';
const makeMockRepo = (): { [K in keyof IIndustrialParkRepository]: ReturnType<typeof vi.fn> } => ({
findById: vi.fn(),
findBySlug: vi.fn(),
findDetailBySlug: vi.fn(),
findDetailById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
search: vi.fn(),
compareParks: vi.fn(),
getStats: vi.fn(),
getMarketData: vi.fn(),
});
const sampleDetail: IndustrialParkDetailData = {
id: 'park-1',
name: 'KCN Bình Dương',
nameEn: 'Binh Duong IP',
slug: 'kcn-binh-duong',
developer: 'Becamex',
operator: null,
status: 'OPERATIONAL',
latitude: 11.05,
longitude: 106.67,
address: '123 Đại lộ BD',
district: 'Thủ Dầu Một',
province: 'Bình Dương',
region: 'SOUTH',
totalAreaHa: 500,
leasableAreaHa: 400,
occupancyRate: 72,
remainingAreaHa: 112,
tenantCount: 45,
establishedYear: 2005,
landRentUsdM2Year: 120,
rbfRentUsdM2Month: 5.5,
rbwRentUsdM2Month: 4.8,
managementFeeUsd: 0.8,
infrastructure: null,
connectivity: null,
incentives: null,
targetIndustries: ['Electronics'],
existingTenants: null,
certifications: null,
media: null,
documents: null,
description: null,
descriptionEn: null,
isVerified: true,
listingCount: 3,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-06-01'),
};
describe('GetIndustrialParkHandler', () => {
let handler: GetIndustrialParkHandler;
let mockRepo: ReturnType<typeof makeMockRepo>;
beforeEach(() => {
mockRepo = makeMockRepo();
handler = new GetIndustrialParkHandler(mockRepo as any);
});
it('returns detail by slug first', async () => {
mockRepo.findDetailBySlug.mockResolvedValue(sampleDetail);
const result = await handler.execute(new GetIndustrialParkQuery('kcn-binh-duong'));
expect(result).toEqual(sampleDetail);
expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('kcn-binh-duong');
expect(mockRepo.findDetailById).not.toHaveBeenCalled();
});
it('falls back to findDetailById if slug not found', async () => {
mockRepo.findDetailBySlug.mockResolvedValue(null);
mockRepo.findDetailById.mockResolvedValue(sampleDetail);
const result = await handler.execute(new GetIndustrialParkQuery('park-1'));
expect(result).toEqual(sampleDetail);
expect(mockRepo.findDetailById).toHaveBeenCalledWith('park-1');
});
it('returns null when park not found by slug or id', async () => {
mockRepo.findDetailBySlug.mockResolvedValue(null);
mockRepo.findDetailById.mockResolvedValue(null);
const result = await handler.execute(new GetIndustrialParkQuery('nonexistent'));
expect(result).toBeNull();
});
});
describe('ListIndustrialParksHandler', () => {
let handler: ListIndustrialParksHandler;
let mockRepo: ReturnType<typeof makeMockRepo>;
beforeEach(() => {
mockRepo = makeMockRepo();
handler = new ListIndustrialParksHandler(mockRepo as any);
});
it('delegates search to repository with correct params', async () => {
const paginatedResult = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockRepo.search.mockResolvedValue(paginatedResult);
const query = new ListIndustrialParksQuery('electronics', 'Bình Dương', 'SOUTH', 'OPERATIONAL', 100, 150, 'Electronics', 2, 10);
const result = await handler.execute(query);
expect(result).toEqual(paginatedResult);
expect(mockRepo.search).toHaveBeenCalledWith({
query: 'electronics',
province: 'Bình Dương',
region: 'SOUTH',
status: 'OPERATIONAL',
minAreaHa: 100,
maxRentUsdM2: 150,
targetIndustry: 'Electronics',
page: 2,
limit: 10,
});
});
it('uses default page and limit', async () => {
mockRepo.search.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
await handler.execute(new ListIndustrialParksQuery());
expect(mockRepo.search).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, limit: 20 }),
);
});
});
describe('CompareIndustrialParksHandler', () => {
let handler: CompareIndustrialParksHandler;
let mockRepo: ReturnType<typeof makeMockRepo>;
beforeEach(() => {
mockRepo = makeMockRepo();
handler = new CompareIndustrialParksHandler(mockRepo as any);
});
it('returns comparison data for valid park ids', async () => {
mockRepo.compareParks.mockResolvedValue([sampleDetail, { ...sampleDetail, id: 'park-2' }]);
const result = await handler.execute(new CompareIndustrialParksQuery(['park-1', 'park-2']));
expect(result).toHaveLength(2);
expect(mockRepo.compareParks).toHaveBeenCalledWith(['park-1', 'park-2']);
});
it('throws ValidationException for fewer than 2 ids', async () => {
await expect(
handler.execute(new CompareIndustrialParksQuery(['park-1'])),
).rejects.toThrow(ValidationException);
});
it('throws ValidationException for more than 5 ids', async () => {
await expect(
handler.execute(new CompareIndustrialParksQuery(['a', 'b', 'c', 'd', 'e', 'f'])),
).rejects.toThrow(ValidationException);
});
});

View File

@@ -0,0 +1,131 @@
'use client';
import { Factory, Map } from 'lucide-react';
import * as React from 'react';
import { ParkCard } from '@/components/khu-cong-nghiep/park-card';
import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar';
import { ParkMap } from '@/components/khu-cong-nghiep/park-map';
import { Button } from '@/components/ui/button';
import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep';
import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api';
const PAGE_SIZE = 12;
export function ParkListingClient() {
const [filters, setFilters] = React.useState<SearchIndustrialParksParams>({
page: 1,
limit: PAGE_SIZE,
});
const [showMap, setShowMap] = React.useState(false);
const { data, isLoading, isError } = useIndustrialParksSearch(filters);
const handleFilterChange = (newFilters: SearchIndustrialParksParams) => {
setFilters({ ...newFilters, limit: PAGE_SIZE });
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageChange = (page: number) => {
setFilters((prev) => ({ ...prev, page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<>
{/* Filters */}
<ParkFilterBar params={filters} onChange={handleFilterChange} />
{/* Map toggle */}
<div className="mt-4 flex justify-end">
<Button
variant={showMap ? 'default' : 'outline'}
size="sm"
className="gap-2"
onClick={() => setShowMap(!showMap)}
>
<Map className="h-4 w-4" />
{showMap ? '\u1EA8n b\u1EA3n \u0111\u1ED3' : 'Xem b\u1EA3n \u0111\u1ED3'}
</Button>
</div>
{/* Park Map */}
{showMap && data && data.data.length > 0 && (
<div className="mt-4">
<ParkMap parks={data.data} />
</div>
)}
{/* Results */}
<div className="mt-6">
{isLoading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-72 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Kh\u00F4ng th\u1EC3 t\u1EA3i danh s\u00E1ch khu c\u00F4ng nghi\u1EC7p. Vui l\u00F2ng th\u1EED l\u1EA1i.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setFilters({ ...filters })}
>
Th\u1EED l\u1EA1i
</Button>
</div>
) : data && data.data.length > 0 ? (
<>
<p className="mb-4 text-sm text-muted-foreground">
{data.total} khu c\u00F4ng nghi\u1EC7p \u0111\u01B0\u1EE3c t\u00ECm th\u1EA5y
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.data.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange((filters.page || 1) - 1)}
>
Tr\u01B0\u1EDBc
</Button>
<span className="text-sm text-muted-foreground">
Trang {data.page} / {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() => handlePageChange((filters.page || 1) + 1)}
>
Sau
</Button>
</div>
)}
</>
) : (
<div className="py-12 text-center">
<Factory className="mx-auto h-12 w-12 text-muted-foreground/30" />
<p className="mt-4 text-lg font-medium">Kh\u00F4ng t\u00ECm th\u1EA5y khu c\u00F4ng nghi\u1EC7p</p>
<p className="mt-1 text-sm text-muted-foreground">
Th\u1EED thay \u0111\u1ED5i b\u1ED9 l\u1ECDc \u0111\u1EC3 t\u00ECm ki\u1EBFm nhi\u1EC1u h\u01A1n
</p>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,333 @@
/**
* Industrial-listing label-density report (TEC-2769 — R5.2.2).
*
* Lightweight monitoring query. Answers: do we have enough labelled
* listing-level data (with `priceUsdM2` populated) to train a listing-level
* AVM for industrial real estate?
*
* Emits:
* - Human-readable table to stdout (default).
* - JSON blob to stdout when `--json` is passed (for CI / dashboards).
*
* Report sections:
* - Totals (rows, rows with priceUsdM2, % label coverage).
* - Histogram by VietnamRegion.
* - Histogram by IndustrialPropertyType.
* - Park coverage (parks with ≥1 listing / total parks).
* - priceUsdM2 P10/P50/P90 by (region, propertyType).
* - Acceptance-gate verdict for TEC-2769 (≥500 rows, NORTH/SOUTH ≥20%,
* CENTRAL ≥5%, every propertyType enum ≥10 rows).
*
* Usage:
* pnpm tsx scripts/report-industrial-label-density.ts
* pnpm tsx scripts/report-industrial-label-density.ts --json
*/
import { PrismaPg } from '@prisma/adapter-pg';
import {
IndustrialPropertyType,
PrismaClient,
VietnamRegion,
} from '@prisma/client';
import pg from 'pg';
// ---------------------------------------------------------------------------
// Types.
// ---------------------------------------------------------------------------
type Verdict = 'PASS' | 'FAIL';
interface Percentiles {
p10: number | null;
p50: number | null;
p90: number | null;
n: number;
}
interface Report {
generatedAt: string;
totals: {
listings: number;
withPriceUsdM2: number;
labelCoveragePct: number;
};
regionHistogram: Record<string, { rows: number; withPrice: number }>;
propertyTypeHistogram: Record<string, { rows: number; withPrice: number }>;
parkCoverage: {
parksWithListings: number;
parksTotal: number;
coveragePct: number;
};
priceBuckets: Array<{
region: VietnamRegion;
propertyType: IndustrialPropertyType;
percentiles: Percentiles;
}>;
gates: {
totalAtLeast500: Verdict;
northAtLeast20pct: Verdict;
southAtLeast20pct: Verdict;
centralAtLeast5pct: Verdict;
everyPropertyTypeAtLeast10: Verdict;
overall: Verdict;
};
}
// ---------------------------------------------------------------------------
// Percentile helper. Linear interpolation on sorted array.
// ---------------------------------------------------------------------------
function percentile(sorted: number[], p: number): number | null {
if (sorted.length === 0) return null;
if (sorted.length === 1) return sorted[0] ?? null;
const rank = (p / 100) * (sorted.length - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return sorted[lo] ?? null;
const loVal = sorted[lo] ?? 0;
const hiVal = sorted[hi] ?? 0;
return loVal + (hiVal - loVal) * (rank - lo);
}
function pct(x: number, total: number): number {
if (total === 0) return 0;
return Math.round((x / total) * 1000) / 10;
}
function fmtPrice(n: number | null): string {
if (n == null) return ' — ';
if (n >= 100) return n.toFixed(0).padStart(6);
return n.toFixed(2).padStart(6);
}
// ---------------------------------------------------------------------------
// Main.
// ---------------------------------------------------------------------------
async function run(): Promise<Report> {
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
try {
const parksTotal = await prisma.industrialPark.count();
const listings = await prisma.industrialListing.findMany({
select: {
id: true,
parkId: true,
propertyType: true,
priceUsdM2: true,
park: { select: { region: true } },
},
});
const total = listings.length;
const withPrice = listings.filter((l) => l.priceUsdM2 != null).length;
// Region histogram.
const regionHistogram: Report['regionHistogram'] = {};
for (const region of Object.values(VietnamRegion)) {
regionHistogram[region] = { rows: 0, withPrice: 0 };
}
for (const l of listings) {
const r = l.park?.region;
if (!r) continue;
const bucket = regionHistogram[r];
if (!bucket) continue;
bucket.rows += 1;
if (l.priceUsdM2 != null) bucket.withPrice += 1;
}
// PropertyType histogram.
const propertyTypeHistogram: Report['propertyTypeHistogram'] = {};
for (const pt of Object.values(IndustrialPropertyType)) {
propertyTypeHistogram[pt] = { rows: 0, withPrice: 0 };
}
for (const l of listings) {
const bucket = propertyTypeHistogram[l.propertyType];
if (!bucket) continue;
bucket.rows += 1;
if (l.priceUsdM2 != null) bucket.withPrice += 1;
}
// Park coverage.
const parkIdsWithListings = new Set(listings.map((l) => l.parkId));
const parkCoverage = {
parksWithListings: parkIdsWithListings.size,
parksTotal,
coveragePct: pct(parkIdsWithListings.size, parksTotal),
};
// Price percentiles by (region, propertyType).
const priceBuckets: Report['priceBuckets'] = [];
for (const region of Object.values(VietnamRegion)) {
for (const propertyType of Object.values(IndustrialPropertyType)) {
const values = listings
.filter(
(l) =>
l.park?.region === region &&
l.propertyType === propertyType &&
l.priceUsdM2 != null,
)
.map((l) => Number(l.priceUsdM2))
.sort((a, b) => a - b);
priceBuckets.push({
region,
propertyType,
percentiles: {
n: values.length,
p10: percentile(values, 10),
p50: percentile(values, 50),
p90: percentile(values, 90),
},
});
}
}
// Acceptance gates.
const northPct = pct(regionHistogram[VietnamRegion.NORTH]?.rows ?? 0, total);
const southPct = pct(regionHistogram[VietnamRegion.SOUTH]?.rows ?? 0, total);
const centralPct = pct(
regionHistogram[VietnamRegion.CENTRAL]?.rows ?? 0,
total,
);
const everyPropertyTypeAtLeast10: Verdict = Object.values(
propertyTypeHistogram,
).every((b) => b.rows >= 10)
? 'PASS'
: 'FAIL';
const gates: Report['gates'] = {
totalAtLeast500: total >= 500 ? 'PASS' : 'FAIL',
northAtLeast20pct: northPct >= 20 ? 'PASS' : 'FAIL',
southAtLeast20pct: southPct >= 20 ? 'PASS' : 'FAIL',
centralAtLeast5pct: centralPct >= 5 ? 'PASS' : 'FAIL',
everyPropertyTypeAtLeast10,
overall: 'FAIL',
};
gates.overall =
gates.totalAtLeast500 === 'PASS' &&
gates.northAtLeast20pct === 'PASS' &&
gates.southAtLeast20pct === 'PASS' &&
gates.centralAtLeast5pct === 'PASS' &&
gates.everyPropertyTypeAtLeast10 === 'PASS'
? 'PASS'
: 'FAIL';
return {
generatedAt: new Date().toISOString(),
totals: {
listings: total,
withPriceUsdM2: withPrice,
labelCoveragePct: pct(withPrice, total),
},
regionHistogram,
propertyTypeHistogram,
parkCoverage,
priceBuckets,
gates,
};
} finally {
await prisma.$disconnect();
await pool.end();
}
}
function renderHuman(report: Report): string {
const out: string[] = [];
const push = (s = ''): void => {
out.push(s);
};
push('');
push('━'.repeat(72));
push(' Industrial listing — label density report');
push(` generated: ${report.generatedAt}`);
push('━'.repeat(72));
push('');
push(' TOTALS');
push(` listings: ${report.totals.listings}`);
push(
` with priceUsdM2: ${report.totals.withPriceUsdM2} ` +
`(${report.totals.labelCoveragePct}%)`,
);
push('');
push(' BY REGION');
push(' region | rows | withPrice');
push(' ---------+------+----------');
for (const [region, b] of Object.entries(report.regionHistogram)) {
push(
` ${region.padEnd(8)} | ${String(b.rows).padStart(4)} | ${String(
b.withPrice,
).padStart(8)}`,
);
}
push('');
push(' BY PROPERTY TYPE');
push(' type | rows | withPrice');
push(' --------------------+------+----------');
for (const [pt, b] of Object.entries(report.propertyTypeHistogram)) {
push(
` ${pt.padEnd(19)} | ${String(b.rows).padStart(4)} | ${String(
b.withPrice,
).padStart(8)}`,
);
}
push('');
push(' PARK COVERAGE');
push(
` ${report.parkCoverage.parksWithListings} / ${report.parkCoverage.parksTotal} ` +
`parks have ≥1 listing (${report.parkCoverage.coveragePct}%)`,
);
push('');
push(' priceUsdM2 P10/P50/P90 (USD/m², USD/m²/mo, or USD/m²/yr by type)');
push(' region | type | n | P10 | P50 | P90');
push(' ---------+---------------------+-----+--------+--------+-------');
for (const bucket of report.priceBuckets) {
if (bucket.percentiles.n === 0) continue;
push(
` ${bucket.region.padEnd(8)} | ${bucket.propertyType.padEnd(19)} | ` +
`${String(bucket.percentiles.n).padStart(3)} | ${fmtPrice(
bucket.percentiles.p10,
)} | ${fmtPrice(bucket.percentiles.p50)} | ${fmtPrice(
bucket.percentiles.p90,
)}`,
);
}
push('');
push(' ACCEPTANCE GATES (TEC-2769)');
push(` total ≥ 500 rows: ${report.gates.totalAtLeast500}`);
push(` NORTH ≥ 20% of rows: ${report.gates.northAtLeast20pct}`);
push(` SOUTH ≥ 20% of rows: ${report.gates.southAtLeast20pct}`);
push(` CENTRAL ≥ 5% of rows: ${report.gates.centralAtLeast5pct}`);
push(
` every propertyType ≥ 10 rows: ${report.gates.everyPropertyTypeAtLeast10}`,
);
push(` OVERALL: ${report.gates.overall}`);
push('');
return out.join('\n');
}
async function main(): Promise<void> {
const asJson = process.argv.includes('--json');
try {
const report = await run();
if (asJson) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(renderHuman(report));
}
process.exit(report.gates.overall === 'PASS' ? 0 : 1);
} catch (err) {
console.error('Label-density report failed:', err);
process.exit(2);
}
}
if (require.main === module) {
void main();
}

View File

@@ -0,0 +1,648 @@
/**
* Synthetic industrial listing seed (TEC-2769 — R5.2.2).
*
* Generates a distribution-matched labelled dataset of industrial listings for
* listing-level AVM training. Riding on top of the 12 hand-curated rows in
* `seed-industrial-listings.ts`, this script produces ~600 additional rows
* stratified across region × propertyType × leaseType × park.
*
* ## Provenance / licensing
*
* - All rows are SYNTHETIC. No rows are scraped, copied, or derived from any
* third-party listings platform or transaction feed.
* - No PII. Sellers and agents reuse existing seeded profiles
* (`seed-seller-001`, `seed-seller-002`, `seed-agentprofile-00{1,2,3}`).
* - Generator is deterministic given `SYNTH_SEED` (default `2026`). Re-running
* with the same seed reproduces the same rows via upsert-on-id.
*
* ## Usage
*
* # As part of `pnpm db:seed` (automatic)
* pnpm db:seed
*
* # Standalone
* npx tsx scripts/seed-industrial-listings-synth.ts
*
* # Override generator seed
* SYNTH_SEED=42 npx tsx scripts/seed-industrial-listings-synth.ts
*
* Idempotent: upserts by id (`synth-ind-listing-0001`..`synth-ind-listing-0600`).
*/
import { PrismaPg } from '@prisma/adapter-pg';
import {
IndustrialLeaseType,
IndustrialListingStatus,
IndustrialPropertyType,
PrismaClient,
VietnamRegion,
} from '@prisma/client';
import pg from 'pg';
// ---------------------------------------------------------------------------
// Deterministic RNG (mulberry32) — same seed → same dataset.
// ---------------------------------------------------------------------------
function mulberry32(seedNumber: number): () => number {
let state = seedNumber >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const DEFAULT_SEED = 2026;
const SEED = Number(process.env['SYNTH_SEED'] ?? DEFAULT_SEED);
const rng = mulberry32(SEED);
function rand(): number {
return rng();
}
function randRange(min: number, max: number): number {
return min + rand() * (max - min);
}
function randInt(min: number, max: number): number {
return Math.floor(randRange(min, max + 1));
}
function pickWeighted<T>(items: readonly (readonly [T, number])[]): T {
const total = items.reduce((s, [, w]) => s + w, 0);
let r = rand() * total;
for (const [item, w] of items) {
r -= w;
if (r <= 0) return item;
}
return items[items.length - 1]![0];
}
function roundTo(value: number, decimals: number): number {
const f = 10 ** decimals;
return Math.round(value * f) / f;
}
// ---------------------------------------------------------------------------
// Reference data: the 20 seeded parks, with minimal context the generator
// needs. Must stay in sync with `scripts/seed-industrial-parks.ts`.
// ---------------------------------------------------------------------------
interface ParkRef {
id: string;
region: VietnamRegion;
landRentUsdM2Year: number | null;
rbfRentUsdM2Month: number | null;
rbwRentUsdM2Month: number | null;
managementFeeUsd: number | null;
}
const PARKS: ParkRef[] = [
{ id: 'seed-kcn-001', region: VietnamRegion.NORTH, landRentUsdM2Year: 90, rbfRentUsdM2Month: 5.5, rbwRentUsdM2Month: 4.8, managementFeeUsd: 0.7 },
{ id: 'seed-kcn-002', region: VietnamRegion.SOUTH, landRentUsdM2Year: 180, rbfRentUsdM2Month: 6.5, rbwRentUsdM2Month: 5.8, managementFeeUsd: 0.8 },
{ id: 'seed-kcn-003', region: VietnamRegion.SOUTH, landRentUsdM2Year: 130, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.65 },
{ id: 'seed-kcn-004', region: VietnamRegion.SOUTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-005', region: VietnamRegion.NORTH, landRentUsdM2Year: 100, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-006', region: VietnamRegion.SOUTH, landRentUsdM2Year: 140, rbfRentUsdM2Month: 5.0, rbwRentUsdM2Month: 4.5, managementFeeUsd: 0.5 },
{ id: 'seed-kcn-007', region: VietnamRegion.SOUTH, landRentUsdM2Year: 150, rbfRentUsdM2Month: 5.5, rbwRentUsdM2Month: 4.8, managementFeeUsd: 0.7 },
{ id: 'seed-kcn-008', region: VietnamRegion.NORTH, landRentUsdM2Year: 85, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.55 },
{ id: 'seed-kcn-009', region: VietnamRegion.SOUTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.3, rbwRentUsdM2Month: 3.9, managementFeeUsd: 0.55 },
{ id: 'seed-kcn-010', region: VietnamRegion.SOUTH, landRentUsdM2Year: 120, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-011', region: VietnamRegion.NORTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-012', region: VietnamRegion.NORTH, landRentUsdM2Year: 85, rbfRentUsdM2Month: 4.3, rbwRentUsdM2Month: 3.8, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-013', region: VietnamRegion.SOUTH, landRentUsdM2Year: 100, rbfRentUsdM2Month: 4.2, rbwRentUsdM2Month: 3.7, managementFeeUsd: 0.5 },
{ id: 'seed-kcn-014', region: VietnamRegion.SOUTH, landRentUsdM2Year: 80, rbfRentUsdM2Month: 4.0, rbwRentUsdM2Month: 3.6, managementFeeUsd: 0.5 },
{ id: 'seed-kcn-015', region: VietnamRegion.NORTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 5.0, rbwRentUsdM2Month: 4.5, managementFeeUsd: 0.65 },
{ id: 'seed-kcn-016', region: VietnamRegion.NORTH, landRentUsdM2Year: 105, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.6 },
{ id: 'seed-kcn-017', region: VietnamRegion.SOUTH, landRentUsdM2Year: 95, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 4.2, managementFeeUsd: 0.55 },
{ id: 'seed-kcn-018', region: VietnamRegion.SOUTH, landRentUsdM2Year: 110, rbfRentUsdM2Month: 4.5, rbwRentUsdM2Month: 4.0, managementFeeUsd: 0.55 },
{ id: 'seed-kcn-019', region: VietnamRegion.CENTRAL, landRentUsdM2Year: 60, rbfRentUsdM2Month: 3.8, rbwRentUsdM2Month: 3.4, managementFeeUsd: 0.4 },
{ id: 'seed-kcn-020', region: VietnamRegion.CENTRAL, landRentUsdM2Year: 40, rbfRentUsdM2Month: 3.5, rbwRentUsdM2Month: 3.2, managementFeeUsd: 0.35 },
];
const PARKS_BY_REGION: Record<VietnamRegion, ParkRef[]> = {
[VietnamRegion.NORTH]: PARKS.filter((p) => p.region === VietnamRegion.NORTH),
[VietnamRegion.SOUTH]: PARKS.filter((p) => p.region === VietnamRegion.SOUTH),
[VietnamRegion.CENTRAL]: PARKS.filter((p) => p.region === VietnamRegion.CENTRAL),
};
// ---------------------------------------------------------------------------
// Stratification table: region × propertyType → row count.
// Totals to 600 synthetic rows.
// ---------------------------------------------------------------------------
type StratKey = { region: VietnamRegion; propertyType: IndustrialPropertyType };
const STRATIFICATION: ReadonlyArray<StratKey & { count: number }> = [
// INDUSTRIAL_LAND
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 40 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 55 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, count: 15 },
// READY_BUILT_FACTORY
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 65 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 90 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, count: 15 },
// READY_BUILT_WAREHOUSE
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 55 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 80 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, count: 15 },
// LOGISTICS_CENTER
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 30 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 45 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.LOGISTICS_CENTER, count: 10 },
// OFFICE_IN_PARK
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 25 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 35 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.OFFICE_IN_PARK, count: 10 },
// DATA_CENTER
{ region: VietnamRegion.NORTH, propertyType: IndustrialPropertyType.DATA_CENTER, count: 10 },
{ region: VietnamRegion.SOUTH, propertyType: IndustrialPropertyType.DATA_CENTER, count: 15 },
{ region: VietnamRegion.CENTRAL, propertyType: IndustrialPropertyType.DATA_CENTER, count: 5 },
];
const TOTAL_EXPECTED = STRATIFICATION.reduce((s, x) => s + x.count, 0);
// ---------------------------------------------------------------------------
// Feature bounds per propertyType.
// ---------------------------------------------------------------------------
interface FeatureBounds {
areaM2: [number, number];
ceilingHeightM: [number, number] | null;
floorLoadTonM2: [number, number] | null;
columnSpacingM: [number, number] | null;
dockCount: [number, number] | null;
craneChance: number;
craneCapacityTon: [number, number] | null;
mezzanineChance: number;
officeChance: number;
officeFraction: [number, number]; // fraction of areaM2 used as office
powerCapacityKva: [number, number] | null;
waterSupplyM3Day: [number, number] | null;
pricingMode: 'land' | 'rbf' | 'rbw' | 'office' | 'data_center';
priceBias: number; // multiplier applied to park-anchored price
}
const BOUNDS: Record<IndustrialPropertyType, FeatureBounds> = {
[IndustrialPropertyType.INDUSTRIAL_LAND]: {
areaM2: [2_000, 50_000],
ceilingHeightM: null,
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneChance: 0,
craneCapacityTon: null,
mezzanineChance: 0,
officeChance: 0,
officeFraction: [0, 0],
powerCapacityKva: null,
waterSupplyM3Day: null,
pricingMode: 'land',
priceBias: 1.0,
},
[IndustrialPropertyType.READY_BUILT_FACTORY]: {
areaM2: [800, 12_000],
ceilingHeightM: [8, 14],
floorLoadTonM2: [2, 5],
columnSpacingM: [10, 20],
dockCount: [2, 8],
craneChance: 0.4,
craneCapacityTon: [3, 15],
mezzanineChance: 0.45,
officeChance: 0.8,
officeFraction: [0.03, 0.1],
powerCapacityKva: [200, 2_000],
waterSupplyM3Day: [20, 120],
pricingMode: 'rbf',
priceBias: 1.0,
},
[IndustrialPropertyType.READY_BUILT_WAREHOUSE]: {
areaM2: [500, 20_000],
ceilingHeightM: [9, 14],
floorLoadTonM2: [3, 6],
columnSpacingM: [10, 20],
dockCount: [2, 12],
craneChance: 0.15,
craneCapacityTon: [3, 10],
mezzanineChance: 0.2,
officeChance: 0.6,
officeFraction: [0.02, 0.06],
powerCapacityKva: [100, 600],
waterSupplyM3Day: [10, 80],
pricingMode: 'rbw',
priceBias: 1.0,
},
[IndustrialPropertyType.LOGISTICS_CENTER]: {
areaM2: [4_000, 30_000],
ceilingHeightM: [10, 14],
floorLoadTonM2: [3, 6],
columnSpacingM: [12, 22],
dockCount: [6, 20],
craneChance: 0.05,
craneCapacityTon: [3, 8],
mezzanineChance: 0.1,
officeChance: 0.7,
officeFraction: [0.02, 0.05],
powerCapacityKva: [300, 1_500],
waterSupplyM3Day: [30, 120],
pricingMode: 'rbw',
priceBias: 0.95,
},
[IndustrialPropertyType.OFFICE_IN_PARK]: {
areaM2: [200, 1_500],
ceilingHeightM: [3, 4.5],
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneChance: 0,
craneCapacityTon: null,
mezzanineChance: 0,
officeChance: 1,
officeFraction: [1, 1],
powerCapacityKva: [60, 200],
waterSupplyM3Day: [3, 10],
pricingMode: 'office',
priceBias: 1.5,
},
[IndustrialPropertyType.DATA_CENTER]: {
areaM2: [1_500, 10_000],
ceilingHeightM: [4, 6],
floorLoadTonM2: [5, 10],
columnSpacingM: [9, 15],
dockCount: [0, 2],
craneChance: 0.05,
craneCapacityTon: [2, 5],
mezzanineChance: 0.1,
officeChance: 0.9,
officeFraction: [0.05, 0.15],
powerCapacityKva: [1_500, 8_000],
waterSupplyM3Day: [40, 200],
pricingMode: 'rbf',
priceBias: 1.3,
},
};
// ---------------------------------------------------------------------------
// leaseType distribution per propertyType (sublease bias applied to price).
// ---------------------------------------------------------------------------
const LEASE_TYPE_PROBS: Record<
IndustrialPropertyType,
ReadonlyArray<readonly [IndustrialLeaseType, number]>
> = {
[IndustrialPropertyType.INDUSTRIAL_LAND]: [
[IndustrialLeaseType.LAND_LEASE, 1.0],
],
[IndustrialPropertyType.READY_BUILT_FACTORY]: [
[IndustrialLeaseType.FACTORY_LEASE, 0.9],
[IndustrialLeaseType.SUBLEASE, 0.1],
],
[IndustrialPropertyType.READY_BUILT_WAREHOUSE]: [
[IndustrialLeaseType.WAREHOUSE_LEASE, 0.85],
[IndustrialLeaseType.SUBLEASE, 0.15],
],
[IndustrialPropertyType.LOGISTICS_CENTER]: [
[IndustrialLeaseType.WAREHOUSE_LEASE, 1.0],
],
[IndustrialPropertyType.OFFICE_IN_PARK]: [
[IndustrialLeaseType.FACTORY_LEASE, 1.0],
],
[IndustrialPropertyType.DATA_CENTER]: [
[IndustrialLeaseType.FACTORY_LEASE, 0.6],
[IndustrialLeaseType.SUBLEASE, 0.4],
],
};
const STATUS_PROBS: ReadonlyArray<readonly [IndustrialListingStatus, number]> = [
[IndustrialListingStatus.ACTIVE, 0.8],
[IndustrialListingStatus.DRAFT, 0.1],
[IndustrialListingStatus.RESERVED, 0.05],
[IndustrialListingStatus.LEASED, 0.05],
];
const SELLERS = ['seed-seller-001', 'seed-seller-002'];
const AGENTS = [
'seed-agentprofile-001',
'seed-agentprofile-002',
'seed-agentprofile-003',
null,
];
// ---------------------------------------------------------------------------
// Title / description templates (Vietnamese). Deliberately generic, no PII.
// ---------------------------------------------------------------------------
function titleFor(t: IndustrialPropertyType, areaM2: number, parkId: string): string {
const kcnShort = parkId.replace('seed-kcn-', 'KCN#');
const m2 = Math.round(areaM2);
switch (t) {
case IndustrialPropertyType.INDUSTRIAL_LAND:
return `Đất công nghiệp ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
case IndustrialPropertyType.READY_BUILT_FACTORY:
return `Nhà xưởng xây sẵn ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
case IndustrialPropertyType.READY_BUILT_WAREHOUSE:
return `Kho xưởng xây sẵn ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
case IndustrialPropertyType.LOGISTICS_CENTER:
return `Trung tâm logistics ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
case IndustrialPropertyType.OFFICE_IN_PARK:
return `Văn phòng trong KCN ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
case IndustrialPropertyType.DATA_CENTER:
return `Data center ${m2.toLocaleString('vi-VN')}m² tại ${kcnShort}`;
}
}
function descriptionFor(
t: IndustrialPropertyType,
areaM2: number,
region: VietnamRegion,
): string {
const regionLabel =
region === VietnamRegion.NORTH ? 'miền Bắc' :
region === VietnamRegion.SOUTH ? 'miền Nam' : 'miền Trung';
const m2 = Math.round(areaM2).toLocaleString('vi-VN');
switch (t) {
case IndustrialPropertyType.INDUSTRIAL_LAND:
return `Lô đất công nghiệp ${m2}m² tại ${regionLabel}. Hạ tầng KCN hoàn chỉnh, pháp lý rõ ràng, phù hợp xây dựng nhà máy.`;
case IndustrialPropertyType.READY_BUILT_FACTORY:
return `Nhà xưởng xây sẵn ${m2}m² tại ${regionLabel}. Kết cấu thép tiền chế, nền bê tông chịu tải, hệ thống PCCC, điện 3 pha, nước và xử lý nước thải đầy đủ.`;
case IndustrialPropertyType.READY_BUILT_WAREHOUSE:
return `Kho xưởng xây sẵn ${m2}m² tại ${regionLabel}. Nền epoxy chịu tải, hệ thống dock container, PCCC tự động, phù hợp logistics và phân phối.`;
case IndustrialPropertyType.LOGISTICS_CENTER:
return `Trung tâm logistics ${m2}m² tại ${regionLabel}. Nhiều dock container, bãi xe tải, phù hợp kho ngoại quan và trung chuyển.`;
case IndustrialPropertyType.OFFICE_IN_PARK:
return `Văn phòng trong KCN ${m2}m² tại ${regionLabel}. Điều hòa trung tâm, thang máy, bãi đỗ xe, phù hợp văn phòng điều hành nhà máy lân cận.`;
case IndustrialPropertyType.DATA_CENTER:
return `Data center ${m2}m² tại ${regionLabel}. Nguồn điện dự phòng kép, hệ thống làm mát N+1, kết nối cáp quang đa nhà mạng.`;
}
}
// ---------------------------------------------------------------------------
// Row synthesis.
// ---------------------------------------------------------------------------
interface SynthListing {
id: string;
parkId: string;
sellerId: string;
agentId: string | null;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
}
function priceForRow(
park: ParkRef,
t: IndustrialPropertyType,
leaseType: IndustrialLeaseType,
): { priceUsdM2: number; pricingUnit: string } {
const b = BOUNDS[t];
const noise = randRange(0.85, 1.15);
const subleaseAdj = leaseType === IndustrialLeaseType.SUBLEASE ? 0.9 : 1.0;
switch (b.pricingMode) {
case 'land': {
const base = park.landRentUsdM2Year ?? 80;
return {
priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2),
pricingUnit: 'usd/m2/year',
};
}
case 'rbf':
case 'data_center': {
const base = park.rbfRentUsdM2Month ?? 4.5;
return {
priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2),
pricingUnit: 'usd/m2/month',
};
}
case 'rbw': {
const base = park.rbwRentUsdM2Month ?? 4.0;
return {
priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2),
pricingUnit: 'usd/m2/month',
};
}
case 'office': {
// Office is priced off factory rent with +50% bias (per plan).
const base = park.rbfRentUsdM2Month ?? 4.5;
return {
priceUsdM2: roundTo(base * b.priceBias * noise * subleaseAdj, 2),
pricingUnit: 'usd/m2/month',
};
}
}
}
function generateOne(
index: number,
region: VietnamRegion,
t: IndustrialPropertyType,
): SynthListing {
const regionParks = PARKS_BY_REGION[region];
if (regionParks.length === 0) {
throw new Error(`No parks in region ${region}`);
}
const park = regionParks[Math.floor(rand() * regionParks.length)]!;
const b = BOUNDS[t];
const leaseType = pickWeighted(LEASE_TYPE_PROBS[t]);
const status = pickWeighted(STATUS_PROBS);
const areaM2 = roundTo(randRange(b.areaM2[0], b.areaM2[1]), 0);
const ceilingHeightM = b.ceilingHeightM
? roundTo(randRange(b.ceilingHeightM[0], b.ceilingHeightM[1]), 1)
: null;
const floorLoadTonM2 = b.floorLoadTonM2
? roundTo(randRange(b.floorLoadTonM2[0], b.floorLoadTonM2[1]), 1)
: null;
const columnSpacingM = b.columnSpacingM
? roundTo(randRange(b.columnSpacingM[0], b.columnSpacingM[1]), 0)
: null;
const dockCount = b.dockCount ? randInt(b.dockCount[0], b.dockCount[1]) : null;
const hasCrane = b.craneChance > 0 && rand() < b.craneChance;
const craneCapacityTon =
hasCrane && b.craneCapacityTon
? roundTo(randRange(b.craneCapacityTon[0], b.craneCapacityTon[1]), 0)
: null;
const hasMezzanine = rand() < b.mezzanineChance;
const hasOfficeArea = rand() < b.officeChance;
const officeAreaM2 =
hasOfficeArea && b.officeFraction[1] > 0
? roundTo(areaM2 * randRange(b.officeFraction[0], b.officeFraction[1]), 0)
: null;
const powerCapacityKva = b.powerCapacityKva
? roundTo(randRange(b.powerCapacityKva[0], b.powerCapacityKva[1]), 0)
: null;
const waterSupplyM3Day = b.waterSupplyM3Day
? roundTo(randRange(b.waterSupplyM3Day[0], b.waterSupplyM3Day[1]), 0)
: null;
// Price only populated when status is not DRAFT — DRAFT rows intentionally
// left null to exercise "missing label" paths in the AVM pipeline.
let priceUsdM2: number | null = null;
let pricingUnit: string | null = null;
let totalLeasePrice: number | null = null;
if (status !== IndustrialListingStatus.DRAFT) {
const p = priceForRow(park, t, leaseType);
priceUsdM2 = p.priceUsdM2;
pricingUnit = p.pricingUnit;
totalLeasePrice = roundTo(priceUsdM2 * areaM2, 0);
} else {
// Even DRAFT rows get a pricing unit hint so the AVM can condition on it.
const p = priceForRow(park, t, leaseType);
pricingUnit = p.pricingUnit;
}
const minLeaseYears =
t === IndustrialPropertyType.INDUSTRIAL_LAND ? randInt(20, 30) : randInt(1, 5);
const maxLeaseYears =
t === IndustrialPropertyType.INDUSTRIAL_LAND ? randInt(40, 50) : randInt(5, 20);
const availableOffsetDays = randInt(0, 180);
const availableFrom = new Date(Date.now() + availableOffsetDays * 24 * 60 * 60 * 1000);
const id = `synth-ind-listing-${String(index + 1).padStart(4, '0')}`;
const sellerId = SELLERS[Math.floor(rand() * SELLERS.length)]!;
const agentId = AGENTS[Math.floor(rand() * AGENTS.length)] ?? null;
return {
id,
parkId: park.id,
sellerId,
agentId,
propertyType: t,
leaseType,
status,
title: titleFor(t, areaM2, park.id),
description: descriptionFor(t, areaM2, park.region),
areaM2,
ceilingHeightM,
floorLoadTonM2,
columnSpacingM,
dockCount,
craneCapacityTon,
hasMezzanine,
hasOfficeArea,
officeAreaM2,
priceUsdM2,
pricingUnit,
totalLeasePrice,
managementFee: park.managementFeeUsd,
depositMonths: randInt(2, 6),
minLeaseYears,
maxLeaseYears,
availableFrom,
powerCapacityKva,
waterSupplyM3Day,
};
}
function generateAll(): SynthListing[] {
const rows: SynthListing[] = [];
let i = 0;
for (const bucket of STRATIFICATION) {
for (let j = 0; j < bucket.count; j++) {
rows.push(generateOne(i, bucket.region, bucket.propertyType));
i++;
}
}
return rows;
}
// ---------------------------------------------------------------------------
// DB write path.
// ---------------------------------------------------------------------------
export async function seedIndustrialListingsSynth(): Promise<void> {
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
console.log(`🏭 Seeding synthetic industrial listings (seed=${SEED}, target=${TOTAL_EXPECTED})...`);
const rows = generateAll();
if (rows.length !== TOTAL_EXPECTED) {
throw new Error(
`Generator invariant broken: produced ${rows.length} rows, expected ${TOTAL_EXPECTED}`,
);
}
const now = new Date();
let written = 0;
try {
for (const l of rows) {
const isPublished =
l.status === IndustrialListingStatus.ACTIVE ||
l.status === IndustrialListingStatus.RESERVED;
await prisma.industrialListing.upsert({
where: { id: l.id },
update: {
status: l.status,
priceUsdM2: l.priceUsdM2,
totalLeasePrice: l.totalLeasePrice,
pricingUnit: l.pricingUnit,
},
create: {
id: l.id,
parkId: l.parkId,
sellerId: l.sellerId,
agentId: l.agentId,
propertyType: l.propertyType,
leaseType: l.leaseType,
status: l.status,
title: l.title,
description: l.description,
areaM2: l.areaM2,
ceilingHeightM: l.ceilingHeightM,
floorLoadTonM2: l.floorLoadTonM2,
columnSpacingM: l.columnSpacingM,
dockCount: l.dockCount,
craneCapacityTon: l.craneCapacityTon,
hasMezzanine: l.hasMezzanine,
hasOfficeArea: l.hasOfficeArea,
officeAreaM2: l.officeAreaM2,
priceUsdM2: l.priceUsdM2,
pricingUnit: l.pricingUnit,
totalLeasePrice: l.totalLeasePrice,
managementFee: l.managementFee,
depositMonths: l.depositMonths,
minLeaseYears: l.minLeaseYears,
maxLeaseYears: l.maxLeaseYears,
availableFrom: l.availableFrom,
powerCapacityKva: l.powerCapacityKva,
waterSupplyM3Day: l.waterSupplyM3Day,
viewCount: Math.floor(rand() * 200) + 5,
inquiryCount: Math.floor(rand() * 15),
publishedAt: isPublished ? now : null,
},
});
written++;
}
console.log(`🏭 Seeded ${written} synthetic industrial listings.`);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
// Standalone entry point.
async function main(): Promise<void> {
try {
await seedIndustrialListingsSynth();
} catch (err) {
console.error('Synthetic seed error:', err);
process.exit(1);
}
}
if (require.main === module) {
void main();
}

View File

@@ -0,0 +1,509 @@
/**
* Seed industrial listings — 12 sample listings across 8 parks.
*
* Usage: npx tsx scripts/seed-industrial-listings.ts
* Idempotent: uses upsert on id.
*/
import { PrismaPg } from '@prisma/adapter-pg';
import {
PrismaClient,
IndustrialPropertyType,
IndustrialLeaseType,
IndustrialListingStatus,
} from '@prisma/client';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
interface IndustrialListingSeed {
id: string;
parkId: string;
sellerId: string;
agentId: string | null;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string;
areaM2: number;
ceilingHeightM: number | null;
floorLoadTonM2: number | null;
columnSpacingM: number | null;
dockCount: number | null;
craneCapacityTon: number | null;
hasMezzanine: boolean;
hasOfficeArea: boolean;
officeAreaM2: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
managementFee: number | null;
depositMonths: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
availableFrom: Date | null;
powerCapacityKva: number | null;
waterSupplyM3Day: number | null;
}
const now = new Date();
const oneMonthLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const threeMonthsLater = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
const LISTINGS: IndustrialListingSeed[] = [
// --- VSIP Bac Ninh (seed-kcn-001) — 2 listings ---
{
id: 'seed-ind-listing-001',
parkId: 'seed-kcn-001',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-001',
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
leaseType: IndustrialLeaseType.FACTORY_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Nhà xưởng xây sẵn 3.000m² KCN VSIP Bắc Ninh',
description: 'Nhà xưởng xây sẵn tiêu chuẩn VSIP tại KCN VSIP Bắc Ninh. Kết cấu thép tiền chế, nền bê tông cốt thép chịu tải 3 tấn/m², hệ thống PCCC tự động, điện 3 pha 500kVA.',
areaM2: 3000,
ceilingHeightM: 10,
floorLoadTonM2: 3,
columnSpacingM: 12,
dockCount: 4,
craneCapacityTon: null,
hasMezzanine: true,
hasOfficeArea: true,
officeAreaM2: 200,
priceUsdM2: 5.5,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 16500,
managementFee: 0.7,
depositMonths: 3,
minLeaseYears: 3,
maxLeaseYears: 10,
availableFrom: oneMonthLater,
powerCapacityKva: 500,
waterSupplyM3Day: 50,
},
{
id: 'seed-ind-listing-002',
parkId: 'seed-kcn-001',
sellerId: 'seed-seller-002',
agentId: null,
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
leaseType: IndustrialLeaseType.LAND_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Đất công nghiệp 10.000m² VSIP Bắc Ninh — vị trí đắc địa',
description: 'Lô đất công nghiệp mặt tiền đường chính 40m, gần cổng chính KCN. Phù hợp xây dựng nhà máy sản xuất điện tử, linh kiện.',
areaM2: 10000,
ceilingHeightM: null,
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: false,
officeAreaM2: null,
priceUsdM2: 90,
pricingUnit: 'usd/m2/year',
totalLeasePrice: 900000,
managementFee: 0.7,
depositMonths: 6,
minLeaseYears: 20,
maxLeaseYears: 50,
availableFrom: now,
powerCapacityKva: null,
waterSupplyM3Day: null,
},
// --- Amata Dong Nai (seed-kcn-003) — 2 listings ---
{
id: 'seed-ind-listing-003',
parkId: 'seed-kcn-003',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-002',
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Kho xưởng 5.000m² KCN Amata Đồng Nai — sẵn dock container',
description: 'Kho xưởng xây sẵn với 6 dock container, hệ thống kệ pallet, nền chịu tải 5 tấn/m². Thích hợp logistics và phân phối.',
areaM2: 5000,
ceilingHeightM: 12,
floorLoadTonM2: 5,
columnSpacingM: 15,
dockCount: 6,
craneCapacityTon: 5,
hasMezzanine: false,
hasOfficeArea: true,
officeAreaM2: 150,
priceUsdM2: 5.0,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 25000,
managementFee: 0.65,
depositMonths: 3,
minLeaseYears: 2,
maxLeaseYears: 10,
availableFrom: oneMonthLater,
powerCapacityKva: 300,
waterSupplyM3Day: 30,
},
{
id: 'seed-ind-listing-004',
parkId: 'seed-kcn-003',
sellerId: 'seed-seller-002',
agentId: 'seed-agentprofile-001',
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
leaseType: IndustrialLeaseType.FACTORY_LEASE,
status: IndustrialListingStatus.DRAFT,
title: 'Nhà máy sản xuất 8.000m² Amata — cần bàn giao sớm',
description: 'Nhà máy quy mô lớn với 2 bay sản xuất, cầu trục 10 tấn, hệ thống xử lý nước thải riêng. Đang hoàn thiện, dự kiến bàn giao Q3/2026.',
areaM2: 8000,
ceilingHeightM: 14,
floorLoadTonM2: 5,
columnSpacingM: 18,
dockCount: 8,
craneCapacityTon: 10,
hasMezzanine: true,
hasOfficeArea: true,
officeAreaM2: 500,
priceUsdM2: 4.8,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 38400,
managementFee: 0.65,
depositMonths: 6,
minLeaseYears: 5,
maxLeaseYears: 20,
availableFrom: threeMonthsLater,
powerCapacityKva: 1500,
waterSupplyM3Day: 100,
},
// --- Nam Dinh Vu (seed-kcn-005) ---
{
id: 'seed-ind-listing-005',
parkId: 'seed-kcn-005',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-003',
propertyType: IndustrialPropertyType.LOGISTICS_CENTER,
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Trung tâm logistics 15.000m² KCN Nam Đình Vũ — sát cảng biển',
description: 'Trung tâm logistics hiện đại ngay cảng Đình Vũ, phù hợp cho kho ngoại quan, trung chuyển hàng hóa quốc tế. Hệ thống bãi container 5.000m².',
areaM2: 15000,
ceilingHeightM: 12,
floorLoadTonM2: 5,
columnSpacingM: 20,
dockCount: 12,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: true,
officeAreaM2: 300,
priceUsdM2: 4.8,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 72000,
managementFee: 0.6,
depositMonths: 3,
minLeaseYears: 3,
maxLeaseYears: 15,
availableFrom: now,
powerCapacityKva: 800,
waterSupplyM3Day: 60,
},
// --- Long Hau (seed-kcn-006) ---
{
id: 'seed-ind-listing-006',
parkId: 'seed-kcn-006',
sellerId: 'seed-seller-002',
agentId: 'seed-agentprofile-002',
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
leaseType: IndustrialLeaseType.SUBLEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Kho hàng 2.000m² Long Hậu — cho thuê lại giá tốt',
description: 'Kho hàng cho thuê lại tại KCN Long Hậu, còn 4 năm hợp đồng gốc. Nền epoxy, PCCC đầy đủ, gần cảng Hiệp Phước.',
areaM2: 2000,
ceilingHeightM: 9,
floorLoadTonM2: 3,
columnSpacingM: 10,
dockCount: 2,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: false,
officeAreaM2: null,
priceUsdM2: 4.0,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 8000,
managementFee: 0.5,
depositMonths: 2,
minLeaseYears: 1,
maxLeaseYears: 4,
availableFrom: now,
powerCapacityKva: 200,
waterSupplyM3Day: 15,
},
// --- Thang Long II Hung Yen (seed-kcn-011) ---
{
id: 'seed-ind-listing-007',
parkId: 'seed-kcn-011',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-001',
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
leaseType: IndustrialLeaseType.FACTORY_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Xưởng sản xuất 4.500m² KCN Thăng Long II — tiêu chuẩn Nhật',
description: 'Nhà xưởng tiêu chuẩn Nhật Bản tại KCN Thăng Long II Hưng Yên. Clean room sẵn, hệ thống AHU, phù hợp sản xuất linh kiện điện tử.',
areaM2: 4500,
ceilingHeightM: 10,
floorLoadTonM2: 3,
columnSpacingM: 12,
dockCount: 4,
craneCapacityTon: null,
hasMezzanine: true,
hasOfficeArea: true,
officeAreaM2: 350,
priceUsdM2: 4.5,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 20250,
managementFee: 0.6,
depositMonths: 3,
minLeaseYears: 3,
maxLeaseYears: 15,
availableFrom: oneMonthLater,
powerCapacityKva: 600,
waterSupplyM3Day: 40,
},
// --- Yen Phong Bac Ninh (seed-kcn-012) ---
{
id: 'seed-ind-listing-008',
parkId: 'seed-kcn-012',
sellerId: 'seed-seller-002',
agentId: null,
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
leaseType: IndustrialLeaseType.LAND_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Đất công nghiệp 5.000m² Yên Phong — gần Samsung',
description: 'Lô đất công nghiệp cuối cùng tại KCN Yên Phong, liền kề nhà máy Samsung Display. Hạ tầng hoàn chỉnh, phù hợp nhà cung cấp Samsung.',
areaM2: 5000,
ceilingHeightM: null,
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: false,
officeAreaM2: null,
priceUsdM2: 85,
pricingUnit: 'usd/m2/year',
totalLeasePrice: 425000,
managementFee: 0.6,
depositMonths: 6,
minLeaseYears: 20,
maxLeaseYears: 47,
availableFrom: now,
powerCapacityKva: null,
waterSupplyM3Day: null,
},
// --- DEEP C Hai Phong (seed-kcn-016) ---
{
id: 'seed-ind-listing-009',
parkId: 'seed-kcn-016',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-003',
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
leaseType: IndustrialLeaseType.FACTORY_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Nhà xưởng xanh 6.000m² DEEP C Hải Phòng — EDGE certified',
description: 'Nhà xưởng đạt chứng chỉ EDGE Green Building, mái solar panels 200kWp, hệ thống thu gom nước mưa. Phù hợp doanh nghiệp ESG.',
areaM2: 6000,
ceilingHeightM: 11,
floorLoadTonM2: 3,
columnSpacingM: 15,
dockCount: 6,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: true,
officeAreaM2: 250,
priceUsdM2: 4.5,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 27000,
managementFee: 0.6,
depositMonths: 3,
minLeaseYears: 3,
maxLeaseYears: 15,
availableFrom: threeMonthsLater,
powerCapacityKva: 700,
waterSupplyM3Day: 50,
},
// --- My Phuoc 3 Binh Duong (seed-kcn-017) ---
{
id: 'seed-ind-listing-010',
parkId: 'seed-kcn-017',
sellerId: 'seed-seller-002',
agentId: 'seed-agentprofile-002',
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Kho xưởng 3.500m² Mỹ Phước 3 — gần đường Mỹ Phước Tân Vạn',
description: 'Kho xưởng xây sẵn mặt đường nội khu, gần đường Mỹ Phước - Tân Vạn. Phù hợp kho hàng FMCG, logistics e-commerce.',
areaM2: 3500,
ceilingHeightM: 10,
floorLoadTonM2: 3,
columnSpacingM: 12,
dockCount: 4,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: true,
officeAreaM2: 100,
priceUsdM2: 4.8,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 16800,
managementFee: 0.55,
depositMonths: 3,
minLeaseYears: 2,
maxLeaseYears: 10,
availableFrom: oneMonthLater,
powerCapacityKva: 400,
waterSupplyM3Day: 30,
},
// --- KTG Nhon Trach (seed-kcn-009) ---
{
id: 'seed-ind-listing-011',
parkId: 'seed-kcn-009',
sellerId: 'seed-seller-001',
agentId: 'seed-agentprofile-001',
propertyType: IndustrialPropertyType.OFFICE_IN_PARK,
leaseType: IndustrialLeaseType.FACTORY_LEASE,
status: IndustrialListingStatus.DRAFT,
title: 'Văn phòng trong KCN KTG Nhơn Trạch 500m²',
description: 'Văn phòng mới xây trong KCN KTG Nhơn Trạch, 2 tầng, điều hòa trung tâm, bãi đỗ xe riêng. Phù hợp văn phòng vùng cho nhà máy lân cận.',
areaM2: 500,
ceilingHeightM: 3.5,
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: true,
officeAreaM2: 500,
priceUsdM2: 8.0,
pricingUnit: 'usd/m2/month',
totalLeasePrice: 4000,
managementFee: 0.55,
depositMonths: 2,
minLeaseYears: 1,
maxLeaseYears: 5,
availableFrom: now,
powerCapacityKva: 100,
waterSupplyM3Day: 5,
},
// --- Chu Lai Quang Nam (seed-kcn-020) ---
{
id: 'seed-ind-listing-012',
parkId: 'seed-kcn-020',
sellerId: 'seed-seller-002',
agentId: 'seed-agentprofile-003',
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
leaseType: IndustrialLeaseType.LAND_LEASE,
status: IndustrialListingStatus.ACTIVE,
title: 'Đất KCN Chu Lai 20.000m² — ưu đãi KKTM đặc biệt',
description: 'Lô đất lớn tại KCN Chu Lai, thuộc Khu kinh tế mở Chu Lai với ưu đãi thuế đặc biệt: miễn tiền thuê đất 15 năm, miễn thuế NK toàn bộ. Phù hợp sản xuất ô tô, cơ khí.',
areaM2: 20000,
ceilingHeightM: null,
floorLoadTonM2: null,
columnSpacingM: null,
dockCount: null,
craneCapacityTon: null,
hasMezzanine: false,
hasOfficeArea: false,
officeAreaM2: null,
priceUsdM2: 40,
pricingUnit: 'usd/m2/year',
totalLeasePrice: 800000,
managementFee: 0.35,
depositMonths: 6,
minLeaseYears: 20,
maxLeaseYears: 50,
availableFrom: now,
powerCapacityKva: null,
waterSupplyM3Day: null,
},
];
export async function seedIndustrialListings() {
console.log('🏭 Seeding industrial listings...');
for (const l of LISTINGS) {
const isPublished =
l.status === IndustrialListingStatus.ACTIVE ||
l.status === IndustrialListingStatus.RESERVED;
await prisma.industrialListing.upsert({
where: { id: l.id },
update: {
title: l.title,
status: l.status,
priceUsdM2: l.priceUsdM2,
totalLeasePrice: l.totalLeasePrice,
},
create: {
id: l.id,
parkId: l.parkId,
sellerId: l.sellerId,
agentId: l.agentId,
propertyType: l.propertyType,
leaseType: l.leaseType,
status: l.status,
title: l.title,
description: l.description,
areaM2: l.areaM2,
ceilingHeightM: l.ceilingHeightM,
floorLoadTonM2: l.floorLoadTonM2,
columnSpacingM: l.columnSpacingM,
dockCount: l.dockCount,
craneCapacityTon: l.craneCapacityTon,
hasMezzanine: l.hasMezzanine,
hasOfficeArea: l.hasOfficeArea,
officeAreaM2: l.officeAreaM2,
priceUsdM2: l.priceUsdM2,
pricingUnit: l.pricingUnit,
totalLeasePrice: l.totalLeasePrice,
managementFee: l.managementFee,
depositMonths: l.depositMonths,
minLeaseYears: l.minLeaseYears,
maxLeaseYears: l.maxLeaseYears,
availableFrom: l.availableFrom,
powerCapacityKva: l.powerCapacityKva,
waterSupplyM3Day: l.waterSupplyM3Day,
viewCount: Math.floor(Math.random() * 200) + 5,
inquiryCount: Math.floor(Math.random() * 15),
publishedAt: isPublished ? new Date() : null,
},
});
console.log(`${l.title.slice(0, 60)}...`);
}
console.log(`🏭 Seeded ${LISTINGS.length} industrial listings.`);
}
// Run standalone
async function main() {
try {
await seedIndustrialListings();
} catch (err) {
console.error('Seed error:', err);
process.exit(1);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
if (require.main === module) {
void main();
}

View File

@@ -0,0 +1,857 @@
/**
* Seed industrial parks (KCN) — 20 real Vietnamese parks.
*
* Usage: npx tsx scripts/seed-industrial-parks.ts
* Idempotent: uses upsert on slug unique constraint.
*/
import { PrismaPg } from '@prisma/adapter-pg';
import {
PrismaClient,
IndustrialParkStatus,
VietnamRegion,
} from '@prisma/client';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
interface IndustrialParkSeed {
id: string;
name: string;
nameEn: string;
slug: string;
developer: string;
operator: string | null;
status: IndustrialParkStatus;
lat: number;
lng: number;
address: string;
district: string;
province: string;
region: VietnamRegion;
totalAreaHa: number;
leasableAreaHa: number;
occupancyRate: number;
remainingAreaHa: number;
tenantCount: number;
establishedYear: number;
landRentUsdM2Year: number | null;
rbfRentUsdM2Month: number | null;
rbwRentUsdM2Month: number | null;
managementFeeUsd: number | null;
infrastructure: Record<string, unknown>;
connectivity: Record<string, unknown>;
incentives: Record<string, unknown>;
targetIndustries: string[];
existingTenants: { name: string; country: string; industry: string }[];
certifications: string[];
description: string;
descriptionEn: string;
}
const PARKS: IndustrialParkSeed[] = [
{
id: 'seed-kcn-001',
name: 'KCN VSIP Bắc Ninh',
nameEn: 'VSIP Bac Ninh Industrial Park',
slug: 'vsip-bac-ninh',
developer: 'Vietnam Singapore Industrial Park',
operator: 'VSIP Group',
status: IndustrialParkStatus.OPERATIONAL,
lat: 21.1215,
lng: 106.0763,
address: 'Phường Phù Chẩn, TP Từ Sơn',
district: 'Từ Sơn',
province: 'Bắc Ninh',
region: VietnamRegion.NORTH,
totalAreaHa: 700,
leasableAreaHa: 500,
occupancyRate: 92,
remainingAreaHa: 40,
tenantCount: 250,
establishedYear: 2007,
landRentUsdM2Year: 90,
rbfRentUsdM2Month: 5.5,
rbwRentUsdM2Month: 4.8,
managementFeeUsd: 0.7,
infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 110 }, airport: { name: 'Nội Bài', distanceKm: 35 }, highway: { name: 'QL 1A', distanceKm: 5 }, railway: { name: 'Ga Bắc Ninh', distanceKm: 8 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 3 năm đầu', specialZone: false },
targetIndustries: ['electronics', 'automotive', 'precision engineering', 'food processing'],
existingTenants: [
{ name: 'Samsung Electronics', country: 'Korea', industry: 'electronics' },
{ name: 'Canon Vietnam', country: 'Japan', industry: 'electronics' },
{ name: 'Foxconn', country: 'Taiwan', industry: 'electronics' },
],
certifications: ['ISO 14001', 'Green Industrial Park'],
description: 'KCN VSIP Bắc Ninh là khu công nghiệp liên doanh Việt Nam - Singapore, tọa lạc tại vị trí chiến lược gần Hà Nội. Với hạ tầng đồng bộ và dịch vụ chuyên nghiệp, đây là điểm đến hàng đầu cho các nhà đầu tư FDI.',
descriptionEn: 'VSIP Bac Ninh is a Vietnam-Singapore joint venture industrial park, strategically located near Hanoi. With synchronized infrastructure and professional services, it is a top destination for FDI investors.',
},
{
id: 'seed-kcn-002',
name: 'KCN VSIP Bình Dương I',
nameEn: 'VSIP Binh Duong I Industrial Park',
slug: 'vsip-binh-duong-1',
developer: 'Vietnam Singapore Industrial Park',
operator: 'VSIP Group',
status: IndustrialParkStatus.FULL,
lat: 11.0174,
lng: 106.6094,
address: 'Phường An Phú, TP Thuận An',
district: 'Thuận An',
province: 'Bình Dương',
region: VietnamRegion.SOUTH,
totalAreaHa: 500,
leasableAreaHa: 355,
occupancyRate: 100,
remainingAreaHa: 0,
tenantCount: 380,
establishedYear: 1996,
landRentUsdM2Year: 110,
rbfRentUsdM2Month: 6.0,
rbwRentUsdM2Month: 5.2,
managementFeeUsd: 0.8,
infrastructure: { electricity: '110kV/22kV', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 25 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 20 }, highway: { name: 'ĐL Mỹ Phước - Tân Vạn', distanceKm: 2 }, railway: { name: 'Ga Sóng Thần', distanceKm: 5 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'N/A (đã lấp đầy)', specialZone: false },
targetIndustries: ['electronics', 'garment', 'food processing', 'logistics'],
existingTenants: [
{ name: 'Lego Manufacturing', country: 'Denmark', industry: 'consumer goods' },
{ name: 'Pepsi Vietnam', country: 'USA', industry: 'food processing' },
],
certifications: ['ISO 14001'],
description: 'KCN VSIP Bình Dương I là khu công nghiệp lâu đời nhất của VSIP, đã lấp đầy 100%. Nằm trên trục đường chính kết nối TP.HCM và các tỉnh lân cận.',
descriptionEn: 'VSIP Binh Duong I is the oldest VSIP industrial park, fully occupied at 100%. Located on the main road connecting HCMC and neighboring provinces.',
},
{
id: 'seed-kcn-003',
name: 'KCN Amata Đồng Nai',
nameEn: 'Amata City Bien Hoa Industrial Park',
slug: 'amata-dong-nai',
developer: 'Amata Corporation',
operator: 'Amata Vietnam',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.9457,
lng: 106.8296,
address: 'Phường Long Bình, TP Biên Hòa',
district: 'Biên Hòa',
province: 'Đồng Nai',
region: VietnamRegion.SOUTH,
totalAreaHa: 700,
leasableAreaHa: 490,
occupancyRate: 88,
remainingAreaHa: 59,
tenantCount: 180,
establishedYear: 1994,
landRentUsdM2Year: 95,
rbfRentUsdM2Month: 5.0,
rbwRentUsdM2Month: 4.5,
managementFeeUsd: 0.65,
infrastructure: { electricity: '220kV/110kV', water: '25,000 m³/day', wastewater: '12,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system + emergency center' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 30 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 25 }, highway: { name: 'QL 1A', distanceKm: 2 }, railway: { name: 'Ga Biên Hòa', distanceKm: 8 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false },
targetIndustries: ['automotive', 'electronics', 'chemicals', 'machinery'],
existingTenants: [
{ name: 'Schaeffler', country: 'Germany', industry: 'automotive' },
{ name: 'Bosch Vietnam', country: 'Germany', industry: 'automotive' },
{ name: 'Kimberly-Clark', country: 'USA', industry: 'consumer goods' },
],
certifications: ['ISO 14001', 'OHSAS 18001'],
description: 'KCN Amata Đồng Nai là một trong những KCN lớn nhất miền Nam với quy hoạch kiểu thành phố công nghiệp. Gần sân bay Long Thành đang xây dựng.',
descriptionEn: 'Amata Dong Nai is one of the largest industrial parks in Southern Vietnam with an industrial city-style layout. Near the under-construction Long Thanh airport.',
},
{
id: 'seed-kcn-004',
name: 'KCN Amata Long An',
nameEn: 'Amata City Long An Industrial Park',
slug: 'amata-long-an',
developer: 'Amata Corporation',
operator: 'Amata Vietnam',
status: IndustrialParkStatus.UNDER_CONSTRUCTION,
lat: 10.6589,
lng: 106.4752,
address: 'Xã Hựu Thạnh, Huyện Đức Hòa',
district: 'Đức Hòa',
province: 'Long An',
region: VietnamRegion.SOUTH,
totalAreaHa: 410,
leasableAreaHa: 290,
occupancyRate: 35,
remainingAreaHa: 188,
tenantCount: 25,
establishedYear: 2020,
landRentUsdM2Year: 75,
rbfRentUsdM2Month: 4.5,
rbwRentUsdM2Month: 3.8,
managementFeeUsd: 0.55,
infrastructure: { electricity: '110kV/22kV', water: '10,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 45 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 35 }, highway: { name: 'Vành đai 3 TP.HCM', distanceKm: 8 }, seaport: { name: 'Cảng Hiệp Phước', distanceKm: 30 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 5 năm đầu', specialZone: true },
targetIndustries: ['logistics', 'food processing', 'consumer goods', 'light manufacturing'],
existingTenants: [
{ name: 'Nippon Express', country: 'Japan', industry: 'logistics' },
],
certifications: ['ISO 14001'],
description: 'KCN Amata Long An là dự án mở rộng mới của Amata, hưởng lợi từ vành đai 3 TP.HCM. Giá thuê cạnh tranh và nhiều ưu đãi cho nhà đầu tư.',
descriptionEn: 'Amata Long An is a new expansion project by Amata, benefiting from HCMC Ring Road 3. Competitive rental prices and many incentives for investors.',
},
{
id: 'seed-kcn-005',
name: 'KCN Nam Đình Vũ',
nameEn: 'Nam Dinh Vu Industrial Park',
slug: 'nam-dinh-vu',
developer: 'Sao Đỏ Group',
operator: 'Sao Đỏ Group',
status: IndustrialParkStatus.OPERATIONAL,
lat: 20.8165,
lng: 106.7833,
address: 'Đường Đình Vũ, Quận Hải An',
district: 'Hải An',
province: 'Hải Phòng',
region: VietnamRegion.NORTH,
totalAreaHa: 1329,
leasableAreaHa: 900,
occupancyRate: 75,
remainingAreaHa: 225,
tenantCount: 120,
establishedYear: 2014,
landRentUsdM2Year: 80,
rbfRentUsdM2Month: 4.8,
rbwRentUsdM2Month: 4.0,
managementFeeUsd: 0.6,
infrastructure: { electricity: '110kV/22kV', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Đình Vũ', distanceKm: 2 }, airport: { name: 'Cát Bi', distanceKm: 15 }, highway: { name: 'Cao tốc Hà Nội - Hải Phòng', distanceKm: 10 }, seaport: { name: 'Cảng nước sâu Lạch Huyện', distanceKm: 20 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 7 năm', specialZone: true },
targetIndustries: ['petrochemicals', 'logistics', 'heavy industry', 'steel'],
existingTenants: [
{ name: 'VinFast', country: 'Vietnam', industry: 'automotive' },
{ name: 'Bridgestone', country: 'Japan', industry: 'automotive' },
],
certifications: ['ISO 14001'],
description: 'KCN Nam Đình Vũ có vị trí đắc địa ngay cạnh cảng nước sâu Hải Phòng, là lựa chọn hàng đầu cho ngành logistics và công nghiệp nặng.',
descriptionEn: 'Nam Dinh Vu IP has a prime location next to Hai Phong deep-water port, a top choice for logistics and heavy industry.',
},
{
id: 'seed-kcn-006',
name: 'KCN Long Hậu',
nameEn: 'Long Hau Industrial Park',
slug: 'long-hau',
developer: 'Long Hau Corporation',
operator: 'Long Hau Corporation',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.6108,
lng: 106.7173,
address: 'Xã Long Hậu, Huyện Cần Giuộc',
district: 'Cần Giuộc',
province: 'Long An',
region: VietnamRegion.SOUTH,
totalAreaHa: 311,
leasableAreaHa: 220,
occupancyRate: 85,
remainingAreaHa: 33,
tenantCount: 140,
establishedYear: 2006,
landRentUsdM2Year: 85,
rbfRentUsdM2Month: 4.5,
rbwRentUsdM2Month: 3.8,
managementFeeUsd: 0.5,
infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '6,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hiệp Phước', distanceKm: 5 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 25 }, highway: { name: 'Nguyễn Hữu Thọ', distanceKm: 3 }, seaport: { name: 'Cảng Hiệp Phước', distanceKm: 5 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false },
targetIndustries: ['logistics', 'food processing', 'garment', 'packaging'],
existingTenants: [
{ name: 'DHL Supply Chain', country: 'Germany', industry: 'logistics' },
{ name: 'Yakult Vietnam', country: 'Japan', industry: 'food processing' },
],
certifications: ['ISO 14001'],
description: 'KCN Long Hậu nằm gần cảng Hiệp Phước và khu đô thị Phú Mỹ Hưng, thuận lợi cho logistics và sản xuất nhẹ.',
descriptionEn: 'Long Hau IP is near Hiep Phuoc port and Phu My Hung urban area, convenient for logistics and light manufacturing.',
},
{
id: 'seed-kcn-007',
name: 'KCN Tân Thuận (EPZ)',
nameEn: 'Tan Thuan Export Processing Zone',
slug: 'tan-thuan-epz',
developer: 'Tân Thuận Corporation',
operator: 'Tân Thuận IPC',
status: IndustrialParkStatus.FULL,
lat: 10.7357,
lng: 106.7203,
address: 'Đường Tân Thuận, Quận 7',
district: 'Quận 7',
province: 'TP. Hồ Chí Minh',
region: VietnamRegion.SOUTH,
totalAreaHa: 300,
leasableAreaHa: 210,
occupancyRate: 100,
remainingAreaHa: 0,
tenantCount: 200,
establishedYear: 1991,
landRentUsdM2Year: 130,
rbfRentUsdM2Month: 7.0,
rbwRentUsdM2Month: 6.0,
managementFeeUsd: 0.9,
infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 15 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 12 }, highway: { name: 'Nguyễn Văn Linh', distanceKm: 1 }, seaport: { name: 'Cảng SPCT', distanceKm: 8 } },
incentives: { taxHoliday: 'EPZ ưu đãi đặc biệt: 4 năm miễn', importDuty: 'Miễn thuế NK toàn bộ (EPZ)', landRentReduction: 'N/A (đã lấp đầy)', specialZone: true },
targetIndustries: ['electronics', 'precision engineering', 'software', 'export manufacturing'],
existingTenants: [
{ name: 'Nidec Vietnam', country: 'Japan', industry: 'electronics' },
{ name: 'Texas Instruments', country: 'USA', industry: 'semiconductors' },
],
certifications: ['ISO 14001', 'EPZ certification'],
description: 'KCN Tân Thuận là khu chế xuất đầu tiên của Việt Nam, nằm ngay trung tâm Quận 7 TP.HCM. Đã lấp đầy 100% với hơn 200 doanh nghiệp.',
descriptionEn: 'Tan Thuan is Vietnam\'s first export processing zone, located in District 7, HCMC. Fully occupied with over 200 enterprises.',
},
{
id: 'seed-kcn-008',
name: 'KCN Thăng Long',
nameEn: 'Thang Long Industrial Park',
slug: 'thang-long',
developer: 'Sumitomo Corporation',
operator: 'Thang Long IP Co.',
status: IndustrialParkStatus.FULL,
lat: 21.0468,
lng: 105.7619,
address: 'Xã Võng La, Huyện Đông Anh',
district: 'Đông Anh',
province: 'Hà Nội',
region: VietnamRegion.NORTH,
totalAreaHa: 274,
leasableAreaHa: 198,
occupancyRate: 100,
remainingAreaHa: 0,
tenantCount: 110,
establishedYear: 1997,
landRentUsdM2Year: 105,
rbfRentUsdM2Month: 6.0,
rbwRentUsdM2Month: 5.0,
managementFeeUsd: 0.75,
infrastructure: { electricity: '110kV/22kV', water: '12,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 120 }, airport: { name: 'Nội Bài', distanceKm: 16 }, highway: { name: 'Nội Bài - Lào Cai', distanceKm: 5 }, railway: { name: 'Ga Đông Anh', distanceKm: 10 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'N/A (đã lấp đầy)', specialZone: false },
targetIndustries: ['electronics', 'automotive', 'precision mechanics', 'IT'],
existingTenants: [
{ name: 'Canon Vietnam', country: 'Japan', industry: 'electronics' },
{ name: 'Panasonic', country: 'Japan', industry: 'electronics' },
{ name: 'Toyota Boshoku', country: 'Japan', industry: 'automotive' },
],
certifications: ['ISO 14001', 'Japan quality standards'],
description: 'KCN Thăng Long do Sumitomo phát triển, là KCN tiêu chuẩn Nhật Bản đầu tiên tại Hà Nội. Tập trung các doanh nghiệp Nhật Bản hàng đầu.',
descriptionEn: 'Thang Long IP, developed by Sumitomo, is the first Japanese-standard industrial park in Hanoi. Home to leading Japanese enterprises.',
},
{
id: 'seed-kcn-009',
name: 'KCN KTG Industrial Nhơn Trạch',
nameEn: 'KTG Industrial Nhon Trach',
slug: 'ktg-nhon-trach',
developer: 'KTG Industrial',
operator: 'KTG Industrial',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.7412,
lng: 106.8978,
address: 'Xã Phước Thiền, Huyện Nhơn Trạch',
district: 'Nhơn Trạch',
province: 'Đồng Nai',
region: VietnamRegion.SOUTH,
totalAreaHa: 250,
leasableAreaHa: 180,
occupancyRate: 78,
remainingAreaHa: 40,
tenantCount: 65,
establishedYear: 2018,
landRentUsdM2Year: 80,
rbfRentUsdM2Month: 4.8,
rbwRentUsdM2Month: 4.0,
managementFeeUsd: 0.55,
infrastructure: { electricity: '110kV/22kV', water: '10,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 20 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 15 }, highway: { name: 'Cao tốc Long Thành - Dầu Giây', distanceKm: 5 }, seaport: { name: 'Cảng Phước An', distanceKm: 10 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm đầu', specialZone: false },
targetIndustries: ['logistics', 'e-commerce fulfillment', 'light manufacturing', 'food processing'],
existingTenants: [
{ name: 'Lazada Logistics', country: 'Singapore', industry: 'e-commerce' },
],
certifications: ['ISO 14001'],
description: 'KCN KTG Nhơn Trạch chuyên về nhà xưởng xây sẵn và logistics, gần sân bay Long Thành đang xây dựng.',
descriptionEn: 'KTG Nhon Trach specializes in ready-built factories and logistics, near the under-construction Long Thanh airport.',
},
{
id: 'seed-kcn-010',
name: 'KCN Prodezi Nhơn Trạch',
nameEn: 'Prodezi Nhon Trach Industrial Park',
slug: 'prodezi-nhon-trach',
developer: 'Prodezi Vietnam',
operator: 'Prodezi Vietnam',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.7518,
lng: 106.8845,
address: 'Xã Hiệp Phước, Huyện Nhơn Trạch',
district: 'Nhơn Trạch',
province: 'Đồng Nai',
region: VietnamRegion.SOUTH,
totalAreaHa: 340,
leasableAreaHa: 245,
occupancyRate: 70,
remainingAreaHa: 73,
tenantCount: 55,
establishedYear: 2015,
landRentUsdM2Year: 72,
rbfRentUsdM2Month: 4.2,
rbwRentUsdM2Month: 3.5,
managementFeeUsd: 0.5,
infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '6,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 25 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 12 }, highway: { name: 'QL 51', distanceKm: 8 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 3 năm đầu', specialZone: false },
targetIndustries: ['machinery', 'plastics', 'packaging', 'consumer goods'],
existingTenants: [
{ name: 'Tetra Pak', country: 'Sweden', industry: 'packaging' },
],
certifications: ['ISO 14001'],
description: 'KCN Prodezi Nhơn Trạch với giá thuê cạnh tranh và vị trí gần sân bay Long Thành, phù hợp cho sản xuất và logistics.',
descriptionEn: 'Prodezi Nhon Trach offers competitive rental prices near Long Thanh airport, suitable for manufacturing and logistics.',
},
{
id: 'seed-kcn-011',
name: 'KCN Thăng Long II Hưng Yên',
nameEn: 'Thang Long II Hung Yen Industrial Park',
slug: 'thang-long-2-hung-yen',
developer: 'Sumitomo Corporation',
operator: 'Thang Long IP Co.',
status: IndustrialParkStatus.OPERATIONAL,
lat: 20.8742,
lng: 106.0165,
address: 'Xã Dị Sử, Huyện Mỹ Hào',
district: 'Mỹ Hào',
province: 'Hưng Yên',
region: VietnamRegion.NORTH,
totalAreaHa: 345,
leasableAreaHa: 250,
occupancyRate: 82,
remainingAreaHa: 45,
tenantCount: 85,
establishedYear: 2004,
landRentUsdM2Year: 78,
rbfRentUsdM2Month: 4.5,
rbwRentUsdM2Month: 3.8,
managementFeeUsd: 0.6,
infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 85 }, airport: { name: 'Nội Bài', distanceKm: 50 }, highway: { name: 'QL 5', distanceKm: 3 }, railway: { name: 'Ga Lạc Đạo', distanceKm: 5 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm đầu', specialZone: false },
targetIndustries: ['electronics', 'automotive parts', 'precision engineering'],
existingTenants: [
{ name: 'Sumitomo Electric', country: 'Japan', industry: 'electronics' },
{ name: 'TOTO Vietnam', country: 'Japan', industry: 'ceramics' },
],
certifications: ['ISO 14001', 'Japan quality standards'],
description: 'KCN Thăng Long II là phần mở rộng của KCN Thăng Long tại Hưng Yên, tiếp tục thu hút các nhà đầu tư Nhật Bản.',
descriptionEn: 'Thang Long II is the expansion of Thang Long IP in Hung Yen, continuing to attract Japanese investors.',
},
{
id: 'seed-kcn-012',
name: 'KCN Yên Phong Bắc Ninh',
nameEn: 'Yen Phong Bac Ninh Industrial Park',
slug: 'yen-phong-bac-ninh',
developer: 'Viglacera Corporation',
operator: 'Viglacera',
status: IndustrialParkStatus.OPERATIONAL,
lat: 21.1652,
lng: 106.1184,
address: 'Xã Yên Trung, Huyện Yên Phong',
district: 'Yên Phong',
province: 'Bắc Ninh',
region: VietnamRegion.NORTH,
totalAreaHa: 658,
leasableAreaHa: 460,
occupancyRate: 95,
remainingAreaHa: 23,
tenantCount: 190,
establishedYear: 2008,
landRentUsdM2Year: 85,
rbfRentUsdM2Month: 5.0,
rbwRentUsdM2Month: 4.2,
managementFeeUsd: 0.6,
infrastructure: { electricity: '110kV/22kV', water: '18,000 m³/day', wastewater: '12,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 100 }, airport: { name: 'Nội Bài', distanceKm: 30 }, highway: { name: 'QL 18', distanceKm: 5 }, railway: { name: 'Ga Bắc Ninh', distanceKm: 12 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false },
targetIndustries: ['electronics', 'display manufacturing', 'semiconductors', 'automotive'],
existingTenants: [
{ name: 'Samsung Display', country: 'Korea', industry: 'display' },
{ name: 'Samsung SDI', country: 'Korea', industry: 'batteries' },
{ name: 'Hanwha', country: 'Korea', industry: 'defense/electronics' },
],
certifications: ['ISO 14001'],
description: 'KCN Yên Phong là hub sản xuất Samsung tại Việt Nam, gần lấp đầy với hàng loạt nhà cung cấp Hàn Quốc.',
descriptionEn: 'Yen Phong is Samsung\'s manufacturing hub in Vietnam, nearly full with numerous Korean suppliers.',
},
{
id: 'seed-kcn-013',
name: 'KCN Bà Rịa - Vũng Tàu (BRVT)',
nameEn: 'Ba Ria Vung Tau Industrial Park',
slug: 'ba-ria-vung-tau',
developer: 'Sonadezi',
operator: 'Sonadezi',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.4957,
lng: 107.1672,
address: 'Phường Long Hương, TP Bà Rịa',
district: 'TP Bà Rịa',
province: 'Bà Rịa - Vũng Tàu',
region: VietnamRegion.SOUTH,
totalAreaHa: 450,
leasableAreaHa: 320,
occupancyRate: 72,
remainingAreaHa: 90,
tenantCount: 80,
establishedYear: 2002,
landRentUsdM2Year: 65,
rbfRentUsdM2Month: 3.8,
rbwRentUsdM2Month: 3.2,
managementFeeUsd: 0.45,
infrastructure: { electricity: '220kV/110kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cái Mép - Thị Vải', distanceKm: 20 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 50 }, highway: { name: 'Cao tốc Biên Hòa - Vũng Tàu', distanceKm: 5 }, seaport: { name: 'Cảng Cái Mép', distanceKm: 20 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm đầu', specialZone: true },
targetIndustries: ['oil & gas', 'petrochemicals', 'heavy industry', 'steel', 'logistics'],
existingTenants: [
{ name: 'Posco Vietnam', country: 'Korea', industry: 'steel' },
{ name: 'Hyosung', country: 'Korea', industry: 'chemicals' },
],
certifications: ['ISO 14001'],
description: 'KCN Bà Rịa - Vũng Tàu gần cảng nước sâu Cái Mép - Thị Vải, phù hợp cho công nghiệp nặng và logistics biển.',
descriptionEn: 'BRVT IP is near Cai Mep deep-water port, suitable for heavy industry and maritime logistics.',
},
{
id: 'seed-kcn-014',
name: 'KCN Becamex Bình Phước',
nameEn: 'Becamex Binh Phuoc Industrial Park',
slug: 'becamex-binh-phuoc',
developer: 'Becamex IDC',
operator: 'Becamex IDC',
status: IndustrialParkStatus.UNDER_CONSTRUCTION,
lat: 11.4521,
lng: 106.6438,
address: 'Xã Minh Thành, Huyện Chơn Thành',
district: 'Chơn Thành',
province: 'Bình Phước',
region: VietnamRegion.SOUTH,
totalAreaHa: 4686,
leasableAreaHa: 3200,
occupancyRate: 25,
remainingAreaHa: 2400,
tenantCount: 30,
establishedYear: 2021,
landRentUsdM2Year: 50,
rbfRentUsdM2Month: 3.5,
rbwRentUsdM2Month: 3.0,
managementFeeUsd: 0.4,
infrastructure: { electricity: '110kV/22kV (đang nâng cấp 220kV)', water: '20,000 m³/day', wastewater: '15,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes (đang xây)', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 85 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 80 }, highway: { name: 'QL 13 + cao tốc TP.HCM - Chơn Thành', distanceKm: 3 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 7 năm đầu', specialZone: true },
targetIndustries: ['agriculture processing', 'rubber', 'wood processing', 'light manufacturing'],
existingTenants: [],
certifications: [],
description: 'KCN Becamex Bình Phước là KCN lớn nhất Việt Nam (4.686 ha), giá thuê thấp nhất khu vực, thích hợp cho ngành chế biến nông sản.',
descriptionEn: 'Becamex Binh Phuoc is Vietnam\'s largest industrial park (4,686 ha), with the lowest rental prices, suitable for agro-processing.',
},
{
id: 'seed-kcn-015',
name: 'KCN Đại An Hải Dương',
nameEn: 'Dai An Hai Duong Industrial Park',
slug: 'dai-an-hai-duong',
developer: 'Đại An JSC',
operator: 'Đại An JSC',
status: IndustrialParkStatus.OPERATIONAL,
lat: 20.9178,
lng: 106.3215,
address: 'Xã Đại An, TP Hải Dương',
district: 'TP Hải Dương',
province: 'Hải Dương',
region: VietnamRegion.NORTH,
totalAreaHa: 174,
leasableAreaHa: 130,
occupancyRate: 90,
remainingAreaHa: 13,
tenantCount: 70,
establishedYear: 2003,
landRentUsdM2Year: 70,
rbfRentUsdM2Month: 4.2,
rbwRentUsdM2Month: 3.5,
managementFeeUsd: 0.5,
infrastructure: { electricity: '110kV/22kV', water: '8,000 m³/day', wastewater: '5,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Hải Phòng', distanceKm: 50 }, airport: { name: 'Nội Bài', distanceKm: 60 }, highway: { name: 'QL 5', distanceKm: 2 }, railway: { name: 'Ga Hải Dương', distanceKm: 5 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Thương lượng', specialZone: false },
targetIndustries: ['garment', 'food processing', 'mechanics', 'electronics assembly'],
existingTenants: [
{ name: 'Ford Vietnam (parts)', country: 'USA', industry: 'automotive' },
],
certifications: ['ISO 14001'],
description: 'KCN Đại An nằm trên trục QL 5 Hà Nội - Hải Phòng, gần lấp đầy, phù hợp cho sản xuất và gia công.',
descriptionEn: 'Dai An IP is on the Hanoi-Hai Phong highway corridor, nearly full, suitable for manufacturing and processing.',
},
{
id: 'seed-kcn-016',
name: 'KCN DEEP C Hải Phòng',
nameEn: 'DEEP C Hai Phong Industrial Zones',
slug: 'deep-c-hai-phong',
developer: 'DEEP C (Belgium)',
operator: 'DEEP C Industrial Zones',
status: IndustrialParkStatus.OPERATIONAL,
lat: 20.8312,
lng: 106.7198,
address: 'Phường Đông Hải, Quận Hải An',
district: 'Hải An',
province: 'Hải Phòng',
region: VietnamRegion.NORTH,
totalAreaHa: 3000,
leasableAreaHa: 2100,
occupancyRate: 68,
remainingAreaHa: 672,
tenantCount: 150,
establishedYear: 1997,
landRentUsdM2Year: 75,
rbfRentUsdM2Month: 4.5,
rbwRentUsdM2Month: 3.8,
managementFeeUsd: 0.6,
infrastructure: { electricity: '220kV/110kV', water: '30,000 m³/day', wastewater: '20,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system + emergency center' },
connectivity: { nearestPort: { name: 'Cảng Đình Vũ', distanceKm: 5 }, airport: { name: 'Cát Bi', distanceKm: 12 }, highway: { name: 'Cao tốc Hà Nội - Hải Phòng', distanceKm: 8 }, seaport: { name: 'Cảng Lạch Huyện', distanceKm: 15 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm', specialZone: true },
targetIndustries: ['petrochemicals', 'LNG', 'electronics', 'logistics', 'renewable energy'],
existingTenants: [
{ name: 'LG Display', country: 'Korea', industry: 'display' },
{ name: 'Pegatron', country: 'Taiwan', industry: 'electronics' },
],
certifications: ['ISO 14001', 'EDGE Green Building', 'Belgian quality standards'],
description: 'DEEP C là cụm KCN lớn nhất Hải Phòng do Bỉ phát triển, với cam kết phát triển bền vững và năng lượng tái tạo.',
descriptionEn: 'DEEP C is Hai Phong\'s largest industrial zone cluster, developed by Belgium, with commitment to sustainability and renewable energy.',
},
{
id: 'seed-kcn-017',
name: 'KCN Mỹ Phước 3 Bình Dương',
nameEn: 'My Phuoc 3 Binh Duong Industrial Park',
slug: 'my-phuoc-3-binh-duong',
developer: 'Becamex IDC',
operator: 'Becamex IDC',
status: IndustrialParkStatus.OPERATIONAL,
lat: 11.1245,
lng: 106.5867,
address: 'Phường Mỹ Phước, TP Bến Cát',
district: 'Bến Cát',
province: 'Bình Dương',
region: VietnamRegion.SOUTH,
totalAreaHa: 992,
leasableAreaHa: 700,
occupancyRate: 87,
remainingAreaHa: 91,
tenantCount: 210,
establishedYear: 2006,
landRentUsdM2Year: 82,
rbfRentUsdM2Month: 4.8,
rbwRentUsdM2Month: 4.0,
managementFeeUsd: 0.55,
infrastructure: { electricity: '110kV/22kV', water: '25,000 m³/day', wastewater: '18,000 m³/day', telecom: 'Fiber optic', roads: '6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cát Lái', distanceKm: 40 }, airport: { name: 'Tân Sơn Nhất', distanceKm: 35 }, highway: { name: 'Mỹ Phước - Tân Vạn', distanceKm: 1 } },
incentives: { taxHoliday: '2 năm miễn, 4 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 30% 3 năm', specialZone: false },
targetIndustries: ['furniture', 'garment', 'food processing', 'electronics assembly', 'plastics'],
existingTenants: [
{ name: 'Colgate-Palmolive', country: 'USA', industry: 'consumer goods' },
{ name: 'Kumho Tire', country: 'Korea', industry: 'automotive' },
],
certifications: ['ISO 14001'],
description: 'KCN Mỹ Phước 3 thuộc chuỗi KCN Becamex tại Bến Cát, là trung tâm sản xuất đa ngành lớn nhất Bình Dương.',
descriptionEn: 'My Phuoc 3 is part of Becamex\'s industrial park chain in Ben Cat, the largest multi-industry manufacturing hub in Binh Duong.',
},
{
id: 'seed-kcn-018',
name: 'KCN Phú Mỹ 2 BRVT',
nameEn: 'Phu My 2 Industrial Park',
slug: 'phu-my-2-brvt',
developer: 'Idico-Conac',
operator: 'Idico-Conac',
status: IndustrialParkStatus.OPERATIONAL,
lat: 10.5378,
lng: 107.0412,
address: 'Xã Mỹ Xuân, TX Phú Mỹ',
district: 'TX Phú Mỹ',
province: 'Bà Rịa - Vũng Tàu',
region: VietnamRegion.SOUTH,
totalAreaHa: 380,
leasableAreaHa: 270,
occupancyRate: 65,
remainingAreaHa: 94,
tenantCount: 45,
establishedYear: 2007,
landRentUsdM2Year: 55,
rbfRentUsdM2Month: 3.5,
rbwRentUsdM2Month: 3.0,
managementFeeUsd: 0.4,
infrastructure: { electricity: '220kV/110kV', water: '12,000 m³/day', wastewater: '8,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Cái Mép - Thị Vải', distanceKm: 10 }, airport: { name: 'Long Thành (đang xây)', distanceKm: 40 }, highway: { name: 'QL 51', distanceKm: 3 }, seaport: { name: 'Cảng Cái Mép', distanceKm: 10 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 50% 5 năm đầu', specialZone: true },
targetIndustries: ['petrochemicals', 'steel', 'power generation', 'port logistics'],
existingTenants: [
{ name: 'SCG Vietnam', country: 'Thailand', industry: 'chemicals' },
],
certifications: ['ISO 14001'],
description: 'KCN Phú Mỹ 2 gần cảng nước sâu Cái Mép, giá thuê thấp, phù hợp cho công nghiệp nặng và hóa chất.',
descriptionEn: 'Phu My 2 IP is near Cai Mep deep-water port, low rental prices, suitable for heavy industry and chemicals.',
},
{
id: 'seed-kcn-019',
name: 'KCN WHA Nghệ An',
nameEn: 'WHA Industrial Zone Nghe An',
slug: 'wha-nghe-an',
developer: 'WHA Group (Thailand)',
operator: 'WHA Industrial Development',
status: IndustrialParkStatus.UNDER_CONSTRUCTION,
lat: 18.7485,
lng: 105.7345,
address: 'Xã Nghi Long, Huyện Nghi Lộc',
district: 'Nghi Lộc',
province: 'Nghệ An',
region: VietnamRegion.CENTRAL,
totalAreaHa: 498,
leasableAreaHa: 350,
occupancyRate: 15,
remainingAreaHa: 297,
tenantCount: 8,
establishedYear: 2022,
landRentUsdM2Year: 45,
rbfRentUsdM2Month: 3.0,
rbwRentUsdM2Month: 2.5,
managementFeeUsd: 0.35,
infrastructure: { electricity: '110kV/22kV (đang xây)', water: '8,000 m³/day (phase 1)', wastewater: '5,000 m³/day', telecom: 'Fiber optic', roads: '4 lanes', fire: 'Full system (đang xây)' },
connectivity: { nearestPort: { name: 'Cảng Cửa Lò', distanceKm: 15 }, airport: { name: 'Vinh', distanceKm: 20 }, highway: { name: 'QL 1A', distanceKm: 5 } },
incentives: { taxHoliday: '4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK thiết bị', landRentReduction: 'Giảm 70% 10 năm đầu', specialZone: true },
targetIndustries: ['electronics assembly', 'garment', 'food processing', 'rubber'],
existingTenants: [],
certifications: [],
description: 'KCN WHA Nghệ An do Thái Lan phát triển, giá thuê thấp nhất miền Trung với nhiều ưu đãi đặc biệt cho nhà đầu tư.',
descriptionEn: 'WHA Nghe An, developed by Thailand\'s WHA Group, offers the lowest rental prices in Central Vietnam with special investor incentives.',
},
{
id: 'seed-kcn-020',
name: 'KCN Chu Lai Quảng Nam',
nameEn: 'Chu Lai Open Economic Zone',
slug: 'chu-lai-quang-nam',
developer: 'Trường Hải Auto (THACO)',
operator: 'THACO Chu Lai',
status: IndustrialParkStatus.OPERATIONAL,
lat: 15.4132,
lng: 108.6421,
address: 'Xã Tam Hiệp, Huyện Núi Thành',
district: 'Núi Thành',
province: 'Quảng Nam',
region: VietnamRegion.CENTRAL,
totalAreaHa: 1550,
leasableAreaHa: 1100,
occupancyRate: 55,
remainingAreaHa: 495,
tenantCount: 60,
establishedYear: 2003,
landRentUsdM2Year: 40,
rbfRentUsdM2Month: 2.8,
rbwRentUsdM2Month: 2.2,
managementFeeUsd: 0.35,
infrastructure: { electricity: '110kV/22kV', water: '15,000 m³/day', wastewater: '10,000 m³/day', telecom: 'Fiber optic', roads: '4-6 lanes', fire: 'Full system' },
connectivity: { nearestPort: { name: 'Cảng Kỳ Hà', distanceKm: 5 }, airport: { name: 'Chu Lai', distanceKm: 8 }, highway: { name: 'QL 1A', distanceKm: 3 }, seaport: { name: 'Cảng Kỳ Hà', distanceKm: 5 } },
incentives: { taxHoliday: 'KKTM đặc biệt: 4 năm miễn, 9 năm giảm 50%', importDuty: 'Miễn thuế NK toàn bộ (KKTM)', landRentReduction: 'Miễn tiền thuê đất 15 năm', specialZone: true },
targetIndustries: ['automotive', 'agriculture machinery', 'wood processing', 'seafood processing'],
existingTenants: [
{ name: 'THACO (Kia, Mazda, Peugeot)', country: 'Vietnam', industry: 'automotive' },
{ name: 'THACO Industries', country: 'Vietnam', industry: 'machinery' },
],
certifications: ['ISO 14001', 'Special Economic Zone'],
description: 'KCN Chu Lai thuộc Khu kinh tế mở Chu Lai, do THACO phát triển chủ đạo. Là hub ô tô lớn nhất Việt Nam.',
descriptionEn: 'Chu Lai IP is in Chu Lai Open Economic Zone, primarily developed by THACO. Vietnam\'s largest automotive hub.',
},
];
export async function seedIndustrialParks() {
console.log('🏭 Seeding industrial parks...');
for (const p of PARKS) {
await prisma.$executeRawUnsafe(
`INSERT INTO "IndustrialPark" (
id, name, "nameEn", slug, developer, operator, status, location,
address, district, province, region, "totalAreaHa", "leasableAreaHa",
"occupancyRate", "remainingAreaHa", "tenantCount", "establishedYear",
"landRentUsdM2Year", "rbfRentUsdM2Month", "rbwRentUsdM2Month",
"managementFeeUsd", infrastructure, connectivity, incentives,
"targetIndustries", "existingTenants", certifications, media, documents,
description, "descriptionEn", "isVerified", "createdAt", "updatedAt"
) VALUES (
$1, $2, $3, $4, $5, $6, $7::"IndustrialParkStatus",
ST_SetSRID(ST_MakePoint($8, $9), 4326),
$10, $11, $12, $13::"VietnamRegion", $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb, $25::jsonb, $26::jsonb,
$27::text[], $28::jsonb, $29::jsonb, NULL, NULL,
$30, $31, true, NOW(), NOW()
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
"nameEn" = EXCLUDED."nameEn",
developer = EXCLUDED.developer,
operator = EXCLUDED.operator,
status = EXCLUDED.status,
"occupancyRate" = EXCLUDED."occupancyRate",
"remainingAreaHa" = EXCLUDED."remainingAreaHa",
"tenantCount" = EXCLUDED."tenantCount",
"landRentUsdM2Year" = EXCLUDED."landRentUsdM2Year",
"rbfRentUsdM2Month" = EXCLUDED."rbfRentUsdM2Month",
"rbwRentUsdM2Month" = EXCLUDED."rbwRentUsdM2Month",
"updatedAt" = NOW()`,
p.id,
p.name,
p.nameEn,
p.slug,
p.developer,
p.operator,
p.status,
p.lng, // ST_MakePoint(lng, lat)
p.lat,
p.address,
p.district,
p.province,
p.region,
p.totalAreaHa,
p.leasableAreaHa,
p.occupancyRate,
p.remainingAreaHa,
p.tenantCount,
p.establishedYear,
p.landRentUsdM2Year,
p.rbfRentUsdM2Month,
p.rbwRentUsdM2Month,
p.managementFeeUsd,
JSON.stringify(p.infrastructure),
JSON.stringify(p.connectivity),
JSON.stringify(p.incentives),
p.targetIndustries,
JSON.stringify(p.existingTenants),
JSON.stringify(p.certifications),
p.description,
p.descriptionEn,
);
console.log(`${p.name}`);
}
console.log(`🏭 Seeded ${PARKS.length} industrial parks.`);
}
// Run standalone
async function main() {
try {
await seedIndustrialParks();
} catch (err) {
console.error('Seed error:', err);
process.exit(1);
} finally {
await prisma.$disconnect();
await pool.end();
}
}
if (require.main === module) {
void main();
}