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

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