feat(industrial): update TypeScript types for Float→Decimal USD field migration (GOO-27)

Migration SQL (20260422120000_industrial_usd_to_decimal) and Prisma schema already
reflected Decimal(18,4). This commit completes the TypeScript / frontend layer.

API changes:
- Domain repo interfaces (IndustrialListingListItem, IndustrialListingDetailData,
  IndustrialParkListItem, IndustrialParkDetailData, IndustrialMarketData): USD money
  fields changed from number|null → string|null (PostgreSQL numeric serialises
  as string in raw query results)
- Raw DB interface types in Prisma repositories updated to string|null for
  Decimal columns
- toDomain() mappers: parseFloat() added where entity props require number|null
  for business-logic arithmetic
- estimate-industrial-rent handler: Number() cast on Prisma ORM Decimal objects
  before arithmetic and comparisons

Web changes:
- khu-cong-nghiep-api.ts: IndustrialParkListItem, IndustrialParkDetail,
  IndustrialListingItem, IndustrialMarketData USD fields → string|null with JSDoc
- listing-card.tsx: parseFloat() wrapping for priceUsdM2/totalLeasePrice display
- park-compare-client.tsx: parseFloat() for landRentUsdM2Year in radar score

Note: pre-existing test failures in filter-bar/login/search specs are unrelated
to this migration (confirmed present on branch before this change).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:34:40 +07:00
parent 0329455e9a
commit 36a9b00cf1
9 changed files with 99 additions and 54 deletions

View File

@@ -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,

View File

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

View File

@@ -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<string, unknown> | null;
connectivity: Record<string, unknown> | null;
incentives: Record<string, unknown> | 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 {

View File

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

View File

@@ -242,7 +242,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
}
async getMarketData(): Promise<IndustrialMarketData> {
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<string, unknown> | null,
connectivity: row.connectivity as Record<string, unknown> | null,
incentives: row.incentives as Record<string, unknown> | 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;

View File

@@ -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 =

View File

@@ -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': {

View File

@@ -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<string, string> | null;
connectivity: Record<string, { name: string; distanceKm: number }> | null;
incentives: Record<string, unknown> | 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;

View File

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