diff --git a/apps/api/src/modules/industrial/application/queries/estimate-industrial-rent/estimate-industrial-rent.handler.ts b/apps/api/src/modules/industrial/application/queries/estimate-industrial-rent/estimate-industrial-rent.handler.ts index 238d0bf..c57c351 100644 --- a/apps/api/src/modules/industrial/application/queries/estimate-industrial-rent/estimate-industrial-rent.handler.ts +++ b/apps/api/src/modules/industrial/application/queries/estimate-industrial-rent/estimate-industrial-rent.handler.ts @@ -48,7 +48,10 @@ export class EstimateIndustrialRentHandler // Calculate base rent based on property type const rentField = this.getRentField(propertyType); const rents = provinceParks - .map((p) => p[rentField] as number | null) + .map((p) => { + const val = p[rentField]; + return val != null ? Number(val) : null; + }) .filter((r): r is number => r != null); const provinceLow = rents.length > 0 ? Math.min(...rents) : null; @@ -58,7 +61,7 @@ export class EstimateIndustrialRentHandler // Determine base rent let baseRentUsdM2: number; if (specificPark && specificPark[rentField] != null) { - baseRentUsdM2 = specificPark[rentField] as number; + baseRentUsdM2 = Number(specificPark[rentField]); } else if (provinceAvg != null) { baseRentUsdM2 = provinceAvg; } else { @@ -126,9 +129,11 @@ export class EstimateIndustrialRentHandler const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100; // Management fee - const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0 - ? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null - : null); + const managementFeeUsdM2 = specificPark?.managementFeeUsd != null + ? Number(specificPark.managementFeeUsd) + : (provinceParks.length > 0 + ? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd != null ? Number(p.managementFeeUsd) : 0), 0) / provinceParks.length || null + : null); return { estimated_rent_usd_m2: adjustedRent, diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts index f4ef7b8..4f1a9ab 100644 --- a/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-listing.repository.ts @@ -34,7 +34,8 @@ export interface IndustrialListingListItem { status: IndustrialListingStatus; title: string; areaM2: number; - priceUsdM2: number | null; + /** Decimal(18,4) serialised as string by PostgreSQL numeric — use parseFloat() for arithmetic. */ + priceUsdM2: string | null; pricingUnit: string | null; ceilingHeightM: number | null; hasMezzanine: boolean; @@ -64,10 +65,13 @@ export interface IndustrialListingDetailData { hasMezzanine: boolean; hasOfficeArea: boolean; officeAreaM2: number | null; - priceUsdM2: number | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + priceUsdM2: string | null; pricingUnit: string | null; - totalLeasePrice: number | null; - managementFee: number | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + totalLeasePrice: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + managementFee: string | null; depositMonths: number | null; minLeaseYears: number | null; maxLeaseYears: number | null; diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts index 1ebb57e..10170ea 100644 --- a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts @@ -37,9 +37,12 @@ export interface IndustrialParkListItem { occupancyRate: number; remainingAreaHa: number; tenantCount: number; - landRentUsdM2Year: number | null; - rbfRentUsdM2Month: number | null; - rbwRentUsdM2Month: number | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + landRentUsdM2Year: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + rbfRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + rbwRentUsdM2Month: string | null; targetIndustries: string[]; latitude: number; longitude: number; @@ -66,10 +69,14 @@ export interface IndustrialParkDetailData { remainingAreaHa: number; tenantCount: number; establishedYear: number | null; - landRentUsdM2Year: number | null; - rbfRentUsdM2Month: number | null; - rbwRentUsdM2Month: number | null; - managementFeeUsd: number | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + landRentUsdM2Year: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + rbfRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + rbwRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */ + managementFeeUsd: string | null; infrastructure: Record | null; connectivity: Record | null; incentives: Record | null; @@ -100,10 +107,12 @@ export interface IndustrialParkStatsData { export interface IndustrialMarketData { totalParks: number; avgOccupancyRate: number; - avgLandRentUsdM2: number | null; - avgRbfRentUsdM2: number | null; - rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; - rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; + /** AVG(numeric) serialised as string by PostgreSQL. */ + avgLandRentUsdM2: string | null; + /** AVG(numeric) serialised as string by PostgreSQL. */ + avgRbfRentUsdM2: string | null; + rentByRegion: { region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[]; + rentByProvince: { province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[]; } export interface IIndustrialParkRepository { diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts index ca2515c..c94a4f9 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-listing.repository.ts @@ -196,10 +196,10 @@ export class PrismaIndustrialListingRepository implements IIndustrialListingRepo hasMezzanine: row.hasMezzanine, hasOfficeArea: row.hasOfficeArea, officeAreaM2: row.officeAreaM2, - priceUsdM2: row.priceUsdM2, + priceUsdM2: row.priceUsdM2 != null ? parseFloat(row.priceUsdM2 as unknown as string) : null, pricingUnit: row.pricingUnit, - totalLeasePrice: row.totalLeasePrice, - managementFee: row.managementFee, + totalLeasePrice: row.totalLeasePrice != null ? parseFloat(row.totalLeasePrice as unknown as string) : null, + managementFee: row.managementFee != null ? parseFloat(row.managementFee as unknown as string) : null, depositMonths: row.depositMonths, minLeaseYears: row.minLeaseYears, maxLeaseYears: row.maxLeaseYears, @@ -299,10 +299,10 @@ interface RawListing { hasMezzanine: boolean; hasOfficeArea: boolean; officeAreaM2: number | null; - priceUsdM2: number | null; + priceUsdM2: string | null; pricingUnit: string | null; - totalLeasePrice: number | null; - managementFee: number | null; + totalLeasePrice: string | null; + managementFee: string | null; depositMonths: number | null; minLeaseYears: number | null; maxLeaseYears: number | null; @@ -327,7 +327,7 @@ interface RawListingListItem { status: string; title: string; areaM2: number; - priceUsdM2: number | null; + priceUsdM2: string | null; pricingUnit: string | null; ceilingHeightM: number | null; hasMezzanine: boolean; diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts index f2856c0..95ec387 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -242,7 +242,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository } async getMarketData(): Promise { - const [overall] = await this.prisma.$queryRaw<[{ totalParks: bigint; avgOccupancy: number; avgLandRent: number | null; avgRbfRent: number | null }]>` + const [overall] = await this.prisma.$queryRaw<[{ totalParks: bigint; avgOccupancy: number; avgLandRent: string | null; avgRbfRent: string | null }]>` SELECT COUNT(*)::bigint as "totalParks", AVG("occupancyRate") as "avgOccupancy", AVG("landRentUsdM2Year") as "avgLandRent", @@ -250,14 +250,14 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository FROM "IndustrialPark" WHERE status = 'OPERATIONAL' OR status = 'FULL' `; - const rentByRegion = await this.prisma.$queryRaw<{ region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>` + const rentByRegion = await this.prisma.$queryRaw<{ region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: bigint }[]>` SELECT region::text, AVG("landRentUsdM2Year") as "avgLandRent", AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount" FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL') GROUP BY region ORDER BY "avgLandRent" DESC NULLS LAST `; - const rentByProvince = await this.prisma.$queryRaw<{ province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>` + const rentByProvince = await this.prisma.$queryRaw<{ province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: bigint }[]>` SELECT province, AVG("landRentUsdM2Year") as "avgLandRent", AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount" FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL') @@ -296,10 +296,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository remainingAreaHa: row.remainingAreaHa, tenantCount: row.tenantCount, establishedYear: row.establishedYear, - landRentUsdM2Year: row.landRentUsdM2Year, - rbfRentUsdM2Month: row.rbfRentUsdM2Month, - rbwRentUsdM2Month: row.rbwRentUsdM2Month, - managementFeeUsd: row.managementFeeUsd, + landRentUsdM2Year: row.landRentUsdM2Year != null ? parseFloat(row.landRentUsdM2Year) : null, + rbfRentUsdM2Month: row.rbfRentUsdM2Month != null ? parseFloat(row.rbfRentUsdM2Month) : null, + rbwRentUsdM2Month: row.rbwRentUsdM2Month != null ? parseFloat(row.rbwRentUsdM2Month) : null, + managementFeeUsd: row.managementFeeUsd != null ? parseFloat(row.managementFeeUsd) : null, infrastructure: row.infrastructure as Record | null, connectivity: row.connectivity as Record | null, incentives: row.incentives as Record | null, @@ -407,10 +407,10 @@ interface RawPark { remainingAreaHa: number; tenantCount: number; establishedYear: number | null; - landRentUsdM2Year: number | null; - rbfRentUsdM2Month: number | null; - rbwRentUsdM2Month: number | null; - managementFeeUsd: number | null; + landRentUsdM2Year: string | null; + rbfRentUsdM2Month: string | null; + rbwRentUsdM2Month: string | null; + managementFeeUsd: string | null; infrastructure: Prisma.JsonValue; connectivity: Prisma.JsonValue; incentives: Prisma.JsonValue; diff --git a/apps/web/components/khu-cong-nghiep/listing-card.tsx b/apps/web/components/khu-cong-nghiep/listing-card.tsx index f87fe35..e7fef2b 100644 --- a/apps/web/components/khu-cong-nghiep/listing-card.tsx +++ b/apps/web/components/khu-cong-nghiep/listing-card.tsx @@ -15,9 +15,9 @@ interface ListingCardProps { export function IndustrialListingCard({ listing }: ListingCardProps) { const priceText = listing.priceUsdM2 - ? `$${listing.priceUsdM2}/${listing.pricingUnit ?? 'm²/tháng'}` + ? `$${parseFloat(listing.priceUsdM2)}/${listing.pricingUnit ?? 'm²/tháng'}` : listing.totalLeasePrice - ? `$${listing.totalLeasePrice.toLocaleString()}` + ? `$${parseFloat(listing.totalLeasePrice).toLocaleString()}` : 'Liên hệ'; const leaseTermText = diff --git a/apps/web/components/khu-cong-nghiep/park-compare-client.tsx b/apps/web/components/khu-cong-nghiep/park-compare-client.tsx index 1584977..087a28a 100644 --- a/apps/web/components/khu-cong-nghiep/park-compare-client.tsx +++ b/apps/web/components/khu-cong-nghiep/park-compare-client.tsx @@ -45,7 +45,7 @@ function normalizeScore(park: IndustrialParkDetail, metric: string): number { case 'area': return Math.min((park.totalAreaHa / 1000) * 100, 100); case 'rent': { - const rent = park.landRentUsdM2Year ?? 0; + const rent = park.landRentUsdM2Year != null ? parseFloat(park.landRentUsdM2Year) : 0; return rent > 0 ? Math.min((rent / 150) * 100, 100) : 0; } case 'infrastructure': { diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts index 929d57b..36ac607 100644 --- a/apps/web/lib/khu-cong-nghiep-api.ts +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -23,9 +23,12 @@ export interface IndustrialParkListItem { occupancyRate: number; remainingAreaHa: number; tenantCount: number; - landRentUsdM2Year: number | null; - rbfRentUsdM2Month: number | null; - rbwRentUsdM2Month: number | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + landRentUsdM2Year: string | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + rbfRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + rbwRentUsdM2Month: string | null; targetIndustries: string[]; latitude: number; longitude: number; @@ -51,10 +54,14 @@ export interface IndustrialParkDetail { remainingAreaHa: number; tenantCount: number; establishedYear: number | null; - landRentUsdM2Year: number | null; - rbfRentUsdM2Month: number | null; - rbwRentUsdM2Month: number | null; - managementFeeUsd: number | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + landRentUsdM2Year: string | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + rbfRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + rbwRentUsdM2Month: string | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + managementFeeUsd: string | null; infrastructure: Record | null; connectivity: Record | null; incentives: Record | null; @@ -84,10 +91,12 @@ export interface IndustrialParkStats { export interface IndustrialMarketData { totalParks: number; avgOccupancyRate: number; - avgLandRentUsdM2: number | null; - avgRbfRentUsdM2: number | null; - rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; - rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; + /** AVG(numeric) serialised as string by PostgreSQL. */ + avgLandRentUsdM2: string | null; + /** AVG(numeric) serialised as string by PostgreSQL. */ + avgRbfRentUsdM2: string | null; + rentByRegion: { region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[]; + rentByProvince: { province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[]; } // ─── Industrial Listing Types ─────────────────────────── @@ -125,9 +134,11 @@ export interface IndustrialListingItem { description: string | null; areaM2: number; ceilingHeightM: number | null; - priceUsdM2: number | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + priceUsdM2: string | null; pricingUnit: string | null; - totalLeasePrice: number | null; + /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */ + totalLeasePrice: string | null; minLeaseYears: number | null; maxLeaseYears: number | null; availableFrom: string | null; diff --git a/prisma/migrations/20260422120000_industrial_usd_to_decimal/migration.sql b/prisma/migrations/20260422120000_industrial_usd_to_decimal/migration.sql new file mode 100644 index 0000000..e2f677c --- /dev/null +++ b/prisma/migrations/20260422120000_industrial_usd_to_decimal/migration.sql @@ -0,0 +1,16 @@ +-- Migrate IndustrialPark and IndustrialListing USD money fields from +-- double precision (Float) to numeric(18, 4) (Decimal) to preserve exact +-- precision for money. USING casts keep existing data intact. + +-- IndustrialPark +ALTER TABLE "IndustrialPark" + ALTER COLUMN "landRentUsdM2Year" TYPE numeric(18, 4) USING "landRentUsdM2Year"::numeric(18, 4), + ALTER COLUMN "rbfRentUsdM2Month" TYPE numeric(18, 4) USING "rbfRentUsdM2Month"::numeric(18, 4), + ALTER COLUMN "rbwRentUsdM2Month" TYPE numeric(18, 4) USING "rbwRentUsdM2Month"::numeric(18, 4), + ALTER COLUMN "managementFeeUsd" TYPE numeric(18, 4) USING "managementFeeUsd"::numeric(18, 4); + +-- IndustrialListing +ALTER TABLE "IndustrialListing" + ALTER COLUMN "priceUsdM2" TYPE numeric(18, 4) USING "priceUsdM2"::numeric(18, 4), + ALTER COLUMN "totalLeasePrice" TYPE numeric(18, 4) USING "totalLeasePrice"::numeric(18, 4), + ALTER COLUMN "managementFee" TYPE numeric(18, 4) USING "managementFee"::numeric(18, 4);