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