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 // Calculate base rent based on property type
const rentField = this.getRentField(propertyType); const rentField = this.getRentField(propertyType);
const rents = provinceParks 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); .filter((r): r is number => r != null);
const provinceLow = rents.length > 0 ? Math.min(...rents) : null; const provinceLow = rents.length > 0 ? Math.min(...rents) : null;
@@ -58,7 +61,7 @@ export class EstimateIndustrialRentHandler
// Determine base rent // Determine base rent
let baseRentUsdM2: number; let baseRentUsdM2: number;
if (specificPark && specificPark[rentField] != null) { if (specificPark && specificPark[rentField] != null) {
baseRentUsdM2 = specificPark[rentField] as number; baseRentUsdM2 = Number(specificPark[rentField]);
} else if (provinceAvg != null) { } else if (provinceAvg != null) {
baseRentUsdM2 = provinceAvg; baseRentUsdM2 = provinceAvg;
} else { } else {
@@ -126,9 +129,11 @@ export class EstimateIndustrialRentHandler
const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100; const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100;
// Management fee // Management fee
const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0 const managementFeeUsdM2 = specificPark?.managementFeeUsd != null
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null ? Number(specificPark.managementFeeUsd)
: null); : (provinceParks.length > 0
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd != null ? Number(p.managementFeeUsd) : 0), 0) / provinceParks.length || null
: null);
return { return {
estimated_rent_usd_m2: adjustedRent, estimated_rent_usd_m2: adjustedRent,

View File

@@ -34,7 +34,8 @@ export interface IndustrialListingListItem {
status: IndustrialListingStatus; status: IndustrialListingStatus;
title: string; title: string;
areaM2: number; areaM2: number;
priceUsdM2: number | null; /** Decimal(18,4) serialised as string by PostgreSQL numeric — use parseFloat() for arithmetic. */
priceUsdM2: string | null;
pricingUnit: string | null; pricingUnit: string | null;
ceilingHeightM: number | null; ceilingHeightM: number | null;
hasMezzanine: boolean; hasMezzanine: boolean;
@@ -64,10 +65,13 @@ export interface IndustrialListingDetailData {
hasMezzanine: boolean; hasMezzanine: boolean;
hasOfficeArea: boolean; hasOfficeArea: boolean;
officeAreaM2: number | null; officeAreaM2: number | null;
priceUsdM2: number | null; /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
priceUsdM2: string | null;
pricingUnit: string | null; pricingUnit: string | null;
totalLeasePrice: number | null; /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
managementFee: number | null; totalLeasePrice: string | null;
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
managementFee: string | null;
depositMonths: number | null; depositMonths: number | null;
minLeaseYears: number | null; minLeaseYears: number | null;
maxLeaseYears: number | null; maxLeaseYears: number | null;

View File

@@ -37,9 +37,12 @@ export interface IndustrialParkListItem {
occupancyRate: number; occupancyRate: number;
remainingAreaHa: number; remainingAreaHa: number;
tenantCount: number; tenantCount: number;
landRentUsdM2Year: number | null; /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
rbfRentUsdM2Month: number | null; landRentUsdM2Year: string | null;
rbwRentUsdM2Month: number | 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[]; targetIndustries: string[];
latitude: number; latitude: number;
longitude: number; longitude: number;
@@ -66,10 +69,14 @@ export interface IndustrialParkDetailData {
remainingAreaHa: number; remainingAreaHa: number;
tenantCount: number; tenantCount: number;
establishedYear: number | null; establishedYear: number | null;
landRentUsdM2Year: number | null; /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
rbfRentUsdM2Month: number | null; landRentUsdM2Year: string | null;
rbwRentUsdM2Month: number | null; /** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
managementFeeUsd: number | null; 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; infrastructure: Record<string, unknown> | null;
connectivity: Record<string, unknown> | null; connectivity: Record<string, unknown> | null;
incentives: Record<string, unknown> | null; incentives: Record<string, unknown> | null;
@@ -100,10 +107,12 @@ export interface IndustrialParkStatsData {
export interface IndustrialMarketData { export interface IndustrialMarketData {
totalParks: number; totalParks: number;
avgOccupancyRate: number; avgOccupancyRate: number;
avgLandRentUsdM2: number | null; /** AVG(numeric) serialised as string by PostgreSQL. */
avgRbfRentUsdM2: number | null; avgLandRentUsdM2: string | null;
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; /** AVG(numeric) serialised as string by PostgreSQL. */
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; 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 { export interface IIndustrialParkRepository {

View File

@@ -196,10 +196,10 @@ export class PrismaIndustrialListingRepository implements IIndustrialListingRepo
hasMezzanine: row.hasMezzanine, hasMezzanine: row.hasMezzanine,
hasOfficeArea: row.hasOfficeArea, hasOfficeArea: row.hasOfficeArea,
officeAreaM2: row.officeAreaM2, officeAreaM2: row.officeAreaM2,
priceUsdM2: row.priceUsdM2, priceUsdM2: row.priceUsdM2 != null ? parseFloat(row.priceUsdM2 as unknown as string) : null,
pricingUnit: row.pricingUnit, pricingUnit: row.pricingUnit,
totalLeasePrice: row.totalLeasePrice, totalLeasePrice: row.totalLeasePrice != null ? parseFloat(row.totalLeasePrice as unknown as string) : null,
managementFee: row.managementFee, managementFee: row.managementFee != null ? parseFloat(row.managementFee as unknown as string) : null,
depositMonths: row.depositMonths, depositMonths: row.depositMonths,
minLeaseYears: row.minLeaseYears, minLeaseYears: row.minLeaseYears,
maxLeaseYears: row.maxLeaseYears, maxLeaseYears: row.maxLeaseYears,
@@ -299,10 +299,10 @@ interface RawListing {
hasMezzanine: boolean; hasMezzanine: boolean;
hasOfficeArea: boolean; hasOfficeArea: boolean;
officeAreaM2: number | null; officeAreaM2: number | null;
priceUsdM2: number | null; priceUsdM2: string | null;
pricingUnit: string | null; pricingUnit: string | null;
totalLeasePrice: number | null; totalLeasePrice: string | null;
managementFee: number | null; managementFee: string | null;
depositMonths: number | null; depositMonths: number | null;
minLeaseYears: number | null; minLeaseYears: number | null;
maxLeaseYears: number | null; maxLeaseYears: number | null;
@@ -327,7 +327,7 @@ interface RawListingListItem {
status: string; status: string;
title: string; title: string;
areaM2: number; areaM2: number;
priceUsdM2: number | null; priceUsdM2: string | null;
pricingUnit: string | null; pricingUnit: string | null;
ceilingHeightM: number | null; ceilingHeightM: number | null;
hasMezzanine: boolean; hasMezzanine: boolean;

View File

@@ -242,7 +242,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
} }
async getMarketData(): Promise<IndustrialMarketData> { 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", SELECT COUNT(*)::bigint as "totalParks",
AVG("occupancyRate") as "avgOccupancy", AVG("occupancyRate") as "avgOccupancy",
AVG("landRentUsdM2Year") as "avgLandRent", AVG("landRentUsdM2Year") as "avgLandRent",
@@ -250,14 +250,14 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
FROM "IndustrialPark" WHERE status = 'OPERATIONAL' OR status = 'FULL' 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", SELECT region::text, AVG("landRentUsdM2Year") as "avgLandRent",
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount" AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL') FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
GROUP BY region ORDER BY "avgLandRent" DESC NULLS LAST 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", SELECT province, AVG("landRentUsdM2Year") as "avgLandRent",
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount" AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL') FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
@@ -296,10 +296,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
remainingAreaHa: row.remainingAreaHa, remainingAreaHa: row.remainingAreaHa,
tenantCount: row.tenantCount, tenantCount: row.tenantCount,
establishedYear: row.establishedYear, establishedYear: row.establishedYear,
landRentUsdM2Year: row.landRentUsdM2Year, landRentUsdM2Year: row.landRentUsdM2Year != null ? parseFloat(row.landRentUsdM2Year) : null,
rbfRentUsdM2Month: row.rbfRentUsdM2Month, rbfRentUsdM2Month: row.rbfRentUsdM2Month != null ? parseFloat(row.rbfRentUsdM2Month) : null,
rbwRentUsdM2Month: row.rbwRentUsdM2Month, rbwRentUsdM2Month: row.rbwRentUsdM2Month != null ? parseFloat(row.rbwRentUsdM2Month) : null,
managementFeeUsd: row.managementFeeUsd, managementFeeUsd: row.managementFeeUsd != null ? parseFloat(row.managementFeeUsd) : null,
infrastructure: row.infrastructure as Record<string, unknown> | null, infrastructure: row.infrastructure as Record<string, unknown> | null,
connectivity: row.connectivity as Record<string, unknown> | null, connectivity: row.connectivity as Record<string, unknown> | null,
incentives: row.incentives as Record<string, unknown> | null, incentives: row.incentives as Record<string, unknown> | null,
@@ -407,10 +407,10 @@ interface RawPark {
remainingAreaHa: number; remainingAreaHa: number;
tenantCount: number; tenantCount: number;
establishedYear: number | null; establishedYear: number | null;
landRentUsdM2Year: number | null; landRentUsdM2Year: string | null;
rbfRentUsdM2Month: number | null; rbfRentUsdM2Month: string | null;
rbwRentUsdM2Month: number | null; rbwRentUsdM2Month: string | null;
managementFeeUsd: number | null; managementFeeUsd: string | null;
infrastructure: Prisma.JsonValue; infrastructure: Prisma.JsonValue;
connectivity: Prisma.JsonValue; connectivity: Prisma.JsonValue;
incentives: Prisma.JsonValue; incentives: Prisma.JsonValue;

View File

@@ -15,9 +15,9 @@ interface ListingCardProps {
export function IndustrialListingCard({ listing }: ListingCardProps) { export function IndustrialListingCard({ listing }: ListingCardProps) {
const priceText = listing.priceUsdM2 const priceText = listing.priceUsdM2
? `$${listing.priceUsdM2}/${listing.pricingUnit ?? 'm²/tháng'}` ? `$${parseFloat(listing.priceUsdM2)}/${listing.pricingUnit ?? 'm²/tháng'}`
: listing.totalLeasePrice : listing.totalLeasePrice
? `$${listing.totalLeasePrice.toLocaleString()}` ? `$${parseFloat(listing.totalLeasePrice).toLocaleString()}`
: 'Liên hệ'; : 'Liên hệ';
const leaseTermText = const leaseTermText =

View File

@@ -45,7 +45,7 @@ function normalizeScore(park: IndustrialParkDetail, metric: string): number {
case 'area': case 'area':
return Math.min((park.totalAreaHa / 1000) * 100, 100); return Math.min((park.totalAreaHa / 1000) * 100, 100);
case 'rent': { 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; return rent > 0 ? Math.min((rent / 150) * 100, 100) : 0;
} }
case 'infrastructure': { case 'infrastructure': {

View File

@@ -23,9 +23,12 @@ export interface IndustrialParkListItem {
occupancyRate: number; occupancyRate: number;
remainingAreaHa: number; remainingAreaHa: number;
tenantCount: number; tenantCount: number;
landRentUsdM2Year: number | null; /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
rbfRentUsdM2Month: number | null; landRentUsdM2Year: string | null;
rbwRentUsdM2Month: number | 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[]; targetIndustries: string[];
latitude: number; latitude: number;
longitude: number; longitude: number;
@@ -51,10 +54,14 @@ export interface IndustrialParkDetail {
remainingAreaHa: number; remainingAreaHa: number;
tenantCount: number; tenantCount: number;
establishedYear: number | null; establishedYear: number | null;
landRentUsdM2Year: number | null; /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
rbfRentUsdM2Month: number | null; landRentUsdM2Year: string | null;
rbwRentUsdM2Month: number | null; /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
managementFeeUsd: number | null; 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; infrastructure: Record<string, string> | null;
connectivity: Record<string, { name: string; distanceKm: number }> | null; connectivity: Record<string, { name: string; distanceKm: number }> | null;
incentives: Record<string, unknown> | null; incentives: Record<string, unknown> | null;
@@ -84,10 +91,12 @@ export interface IndustrialParkStats {
export interface IndustrialMarketData { export interface IndustrialMarketData {
totalParks: number; totalParks: number;
avgOccupancyRate: number; avgOccupancyRate: number;
avgLandRentUsdM2: number | null; /** AVG(numeric) serialised as string by PostgreSQL. */
avgRbfRentUsdM2: number | null; avgLandRentUsdM2: string | null;
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; /** AVG(numeric) serialised as string by PostgreSQL. */
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[]; 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 ─────────────────────────── // ─── Industrial Listing Types ───────────────────────────
@@ -125,9 +134,11 @@ export interface IndustrialListingItem {
description: string | null; description: string | null;
areaM2: number; areaM2: number;
ceilingHeightM: number | null; ceilingHeightM: number | null;
priceUsdM2: number | null; /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
priceUsdM2: string | null;
pricingUnit: string | null; pricingUnit: string | null;
totalLeasePrice: number | null; /** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
totalLeasePrice: string | null;
minLeaseYears: number | null; minLeaseYears: number | null;
maxLeaseYears: number | null; maxLeaseYears: number | null;
availableFrom: string | 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);