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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user