feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user