diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts index 852dc7a..e166d56 100644 --- a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts @@ -18,6 +18,13 @@ const DEFAULT_RANGES: Record = { LAND: { min: 5_000_000, max: 800_000_000 }, OFFICE: { min: 10_000_000, max: 300_000_000 }, SHOPHOUSE: { min: 30_000_000, max: 600_000_000 }, + // Phòng trọ: priced per month (1M–10M VND), stored as total price (not per-m²). + // Range reflects typical HCMC room rental market 2024-2026. + ROOM_RENTAL: { min: 1_000_000, max: 10_000_000 }, + // Condotel: mixed-use hotel/condo; higher-end per-m² due to resort factor. + CONDOTEL: { min: 20_000_000, max: 300_000_000 }, + // Serviced apartment: furnished with hotel-style services; premium over standard apartments. + SERVICED_APARTMENT: { min: 20_000_000, max: 250_000_000 }, }; /** Multiplier to widen default ranges for suspicious-but-not-invalid detection */ diff --git a/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts index 09bb511..fefaa91 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/check-quota.handler.spec.ts @@ -24,6 +24,7 @@ describe('CheckQuotaHandler', () => { }, usageRecord: { findFirst: vi.fn(), + findUnique: vi.fn(), }, }; @@ -33,7 +34,9 @@ describe('CheckQuotaHandler', () => { invalidateByPrefix: vi.fn().mockResolvedValue(undefined), }; - handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any); + const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + + handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any); }); it('returns quota for active subscription', async () => { @@ -48,7 +51,7 @@ describe('CheckQuotaHandler', () => { maxListings: 50, maxSavedSearches: 10, }); - mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 15 }); + mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 15 }); const query = new CheckQuotaQuery('user-1', 'listings_created'); const result = await handler.execute(query); @@ -71,7 +74,7 @@ describe('CheckQuotaHandler', () => { id: 'plan-1', maxListings: 5, }); - mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 5 }); + mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 5 }); const query = new CheckQuotaQuery('user-1', 'listings_created'); const result = await handler.execute(query); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts index fb91f5d..31da308 100644 --- a/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts +++ b/apps/api/src/modules/subscriptions/application/__tests__/meter-usage.handler.spec.ts @@ -28,9 +28,7 @@ describe('MeterUsageHandler', () => { mockPrisma = { usageRecord: { - findFirst: vi.fn(), - create: vi.fn(), - update: vi.fn(), + upsert: vi.fn(), }, }; @@ -50,11 +48,10 @@ describe('MeterUsageHandler', () => { ); }); - it('creates new usage record when none exists', async () => { + it('creates new usage record via atomic upsert when none exists', async () => { const subscription = createActiveSubscription(); mockRepo.findByUserId.mockResolvedValue(subscription); - mockPrisma.usageRecord.findFirst.mockResolvedValue(null); - mockPrisma.usageRecord.create.mockResolvedValue({ + mockPrisma.usageRecord.upsert.mockResolvedValue({ id: 'usage-1', metric: 'listings_created', count: 3, @@ -68,17 +65,30 @@ describe('MeterUsageHandler', () => { expect(result.usageRecordId).toBe('usage-1'); expect(result.metric).toBe('listings_created'); expect(result.count).toBe(3); - expect(mockPrisma.usageRecord.create).toHaveBeenCalledTimes(1); + expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledWith({ + where: { + subscriptionId_metric_periodStart_periodEnd: { + subscriptionId: subscription.id, + metric: 'listings_created', + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }, + update: { count: { increment: 3 } }, + create: { + subscriptionId: subscription.id, + metric: 'listings_created', + count: 3, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }); }); - it('increments existing usage record', async () => { + it('increments existing usage record via atomic upsert', async () => { const subscription = createActiveSubscription(); mockRepo.findByUserId.mockResolvedValue(subscription); - mockPrisma.usageRecord.findFirst.mockResolvedValue({ - id: 'usage-1', - count: 5, - }); - mockPrisma.usageRecord.update.mockResolvedValue({ + mockPrisma.usageRecord.upsert.mockResolvedValue({ id: 'usage-1', metric: 'listings_created', count: 8, @@ -90,17 +100,13 @@ describe('MeterUsageHandler', () => { const result = await handler.execute(command); expect(result.count).toBe(8); - expect(mockPrisma.usageRecord.update).toHaveBeenCalledWith({ - where: { id: 'usage-1' }, - data: { count: 8 }, - }); + expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledTimes(1); }); it('invalidates quota cache after metering usage', async () => { const subscription = createActiveSubscription(); mockRepo.findByUserId.mockResolvedValue(subscription); - mockPrisma.usageRecord.findFirst.mockResolvedValue(null); - mockPrisma.usageRecord.create.mockResolvedValue({ + mockPrisma.usageRecord.upsert.mockResolvedValue({ id: 'usage-1', metric: 'listings_created', count: 1, diff --git a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts index b70aacf..bfe4889 100644 --- a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts @@ -40,34 +40,28 @@ export class MeterUsageHandler implements ICommandHandler { throw new ValidationException('Subscription không ở trạng thái hoạt động'); } - // Upsert usage record for current period + metric - const existing = await this.prisma.usageRecord.findFirst({ + // Atomic upsert using the @@unique constraint to prevent race conditions + const usageRecord = await this.prisma.usageRecord.upsert({ where: { + subscriptionId_metric_periodStart_periodEnd: { + subscriptionId: subscription.id, + metric: command.metric, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }, + update: { + count: { increment: command.count }, + }, + create: { subscriptionId: subscription.id, metric: command.metric, + count: command.count, periodStart: subscription.currentPeriodStart, periodEnd: subscription.currentPeriodEnd, }, }); - let usageRecord; - if (existing) { - usageRecord = await this.prisma.usageRecord.update({ - where: { id: existing.id }, - data: { count: existing.count + command.count }, - }); - } else { - usageRecord = await this.prisma.usageRecord.create({ - data: { - subscriptionId: subscription.id, - metric: command.metric, - count: command.count, - periodStart: subscription.currentPeriodStart, - periodEnd: subscription.currentPeriodEnd, - }, - }); - } - // Invalidate cached quota for this user + metric await this.cache.invalidate( CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric), diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts index 4bca55a..a573936 100644 --- a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts @@ -76,13 +76,15 @@ export class CheckQuotaHandler implements IQueryHandler { throw new NotFoundException('Plan', subscription.planId); } - return this.checkAgainstPlan(plan, metric, subscription.id); + return this.checkAgainstPlan(plan, metric, subscription.id, subscription.currentPeriodStart, subscription.currentPeriodEnd); } private async checkAgainstPlan( plan: Plan, metric: string, subscriptionId: string | null, + periodStart?: Date, + periodEnd?: Date, ): Promise { const planField = METRIC_TO_PLAN_FIELD[metric]; @@ -93,9 +95,22 @@ export class CheckQuotaHandler implements IQueryHandler { const limit = plan[planField] as number; - // Get current usage + // Get current period usage (period-scoped to prevent stale reads) let used = 0; - if (subscriptionId) { + if (subscriptionId && periodStart && periodEnd) { + const usageRecord = await this.prisma.usageRecord.findUnique({ + where: { + subscriptionId_metric_periodStart_periodEnd: { + subscriptionId, + metric, + periodStart, + periodEnd, + }, + }, + }); + used = usageRecord?.count ?? 0; + } else if (subscriptionId) { + // Fallback for free tier (no subscription period) const usageRecord = await this.prisma.usageRecord.findFirst({ where: { subscriptionId, diff --git a/apps/web/app/[locale]/(dashboard)/layout.tsx b/apps/web/app/[locale]/(dashboard)/layout.tsx index bcac5a5..40d7071 100644 --- a/apps/web/app/[locale]/(dashboard)/layout.tsx +++ b/apps/web/app/[locale]/(dashboard)/layout.tsx @@ -302,11 +302,11 @@ export default function AppDashboardLayout({ children }: { children: React.React // TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047) const tickerItems: TickerItem[] = [ { id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' }, - { id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' }, + { id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' }, { id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' }, { id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' }, { id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' }, - { id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' }, + { id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' }, { id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' }, { id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' }, ]; diff --git a/apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx b/apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx index 5ffd178..c15b9cc 100644 --- a/apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx +++ b/apps/web/app/[locale]/(public)/bao-cao/tao-moi/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useGenerateReport } from '@/lib/hooks/use-reports'; import type { ReportType } from '@/lib/reports-api'; +import { HCM_DISTRICTS } from '@/lib/vietnam-geo'; // ─── Constants ───────────────────────────────────────── @@ -18,12 +19,7 @@ const PROVINCES = [ 'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ', ]; -const HCM_DISTRICTS = [ - 'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7', - 'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh', - 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức', - 'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ', -]; +const HCM_DISTRICTS_LIST = HCM_DISTRICTS; const PROPERTY_TYPES = [ { value: 'APARTMENT', label: 'Căn hộ' }, @@ -248,7 +244,7 @@ export default function TaoMoiPage() { className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm" > - {HCM_DISTRICTS.map((d) => ( + {HCM_DISTRICTS_LIST.map((d) => ( ))} @@ -302,7 +298,7 @@ export default function TaoMoiPage() { className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm" > - {HCM_DISTRICTS.map((d) => ( + {HCM_DISTRICTS_LIST.map((d) => ( ))} diff --git a/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx b/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx index 986ba85..69aac98 100644 --- a/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx +++ b/apps/web/app/[locale]/(public)/chuyen-nhuong/page.tsx @@ -14,15 +14,10 @@ import { STATUS_LABELS, } from '@/lib/chuyen-nhuong-api'; import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong'; +import { HCM_DISTRICTS } from '@/lib/vietnam-geo'; const PAGE_SIZE = 12; -const DISTRICTS = [ - 'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7', - 'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh', - 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức', -]; - export default function ChuyenNhuongPage() { const [filters, setFilters] = React.useState({ page: 1, @@ -93,7 +88,7 @@ export default function ChuyenNhuongPage() { aria-label="Quận/Huyện" > - {DISTRICTS.map((d) => ( + {HCM_DISTRICTS.map((d) => ( ))} diff --git a/apps/web/app/[locale]/(public)/layout.tsx b/apps/web/app/[locale]/(public)/layout.tsx index 8d90cd2..8737e87 100644 --- a/apps/web/app/[locale]/(public)/layout.tsx +++ b/apps/web/app/[locale]/(public)/layout.tsx @@ -81,11 +81,11 @@ export default function PublicLayout({ children }: { children: React.ReactNode } const tickerItems: TickerItem[] = [ { id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' }, - { id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' }, + { id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' }, { id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' }, { id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' }, { id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' }, - { id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' }, + { id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' }, { id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' }, { id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' }, ]; diff --git a/apps/web/app/[locale]/(public)/listings/page.tsx b/apps/web/app/[locale]/(public)/listings/page.tsx index a439fb1..a7901c4 100644 --- a/apps/web/app/[locale]/(public)/listings/page.tsx +++ b/apps/web/app/[locale]/(public)/listings/page.tsx @@ -19,17 +19,13 @@ import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { useListingsSearch } from '@/lib/hooks/use-listings'; import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api'; import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; +import { HCM_DISTRICTS } from '@/lib/vietnam-geo'; // --------------------------------------------------------------------------- // Hằng số // --------------------------------------------------------------------------- -const DISTRICTS = [ - 'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7', - 'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', - 'Bình Thạnh', 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức', - 'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ', -]; +const DISTRICTS = HCM_DISTRICTS; const PRICE_RANGES = [ { label: 'Dưới 1 tỷ', min: '', max: '1000000000' }, diff --git a/apps/web/lib/vietnam-geo.ts b/apps/web/lib/vietnam-geo.ts new file mode 100644 index 0000000..5cafaba --- /dev/null +++ b/apps/web/lib/vietnam-geo.ts @@ -0,0 +1,70 @@ +/** + * vietnam-geo.ts + * Centralized Vietnamese administrative geography data. + * + * NOTE: As of 01/01/2021, Quận 2, Quận 9 and the old Thủ Đức district were + * merged into Thành phố Thủ Đức. Use HCM_DISTRICTS for all district-picker UIs. + */ + +/** Current Ho Chi Minh City districts / city-level subdivisions (post-2021). */ +export const HCM_DISTRICTS: readonly string[] = [ + // Inner urban districts (quận nội thành) + 'Quận 1', + 'Quận 3', + 'Quận 4', + 'Quận 5', + 'Quận 6', + 'Quận 7', + 'Quận 8', + 'Quận 10', + 'Quận 11', + 'Quận 12', + // Thu Duc city (merged from former Quận 2, Quận 9, and Thủ Đức district) + 'Thành phố Thủ Đức', + // Suburban districts (quận ngoại thành) + 'Bình Tân', + 'Bình Thạnh', + 'Gò Vấp', + 'Phú Nhuận', + 'Tân Bình', + 'Tân Phú', + // Rural districts (huyện) + 'Bình Chánh', + 'Cần Giờ', + 'Củ Chi', + 'Hóc Môn', + 'Nhà Bè', +] as const; + +/** Major Vietnamese provinces / centrally-administered municipalities. */ +export const PROVINCES: readonly string[] = [ + 'Hồ Chí Minh', + 'Hà Nội', + 'Đà Nẵng', + 'Bình Dương', + 'Đồng Nai', + 'Long An', + 'Bà Rịa - Vũng Tàu', + 'Bắc Ninh', + 'Hải Phòng', + 'Hải Dương', + 'Hưng Yên', + 'Quảng Ninh', + 'Thái Nguyên', + 'Vĩnh Phúc', + 'Cần Thơ', +] as const; + +/** Major cities shown in city pickers across the platform. */ +export const MAJOR_CITIES: readonly string[] = [ + 'Hồ Chí Minh', + 'Hà Nội', + 'Đà Nẵng', + 'Nha Trang', + 'Cần Thơ', + 'Hải Phòng', + 'Bình Dương', + 'Đồng Nai', + 'Long An', + 'Bà Rịa - Vũng Tàu', +] as const; diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9ea8f6e..ace0c73 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -126,7 +126,10 @@ "VILLA": "Villa", "LAND": "Land", "OFFICE": "Office", - "SHOPHOUSE": "Shophouse" + "SHOPHOUSE": "Shophouse", + "ROOM_RENTAL": "Room Rental", + "CONDOTEL": "Condotel", + "SERVICED_APARTMENT": "Serviced Apartment" }, "transactionTypes": { "SALE": "Sale", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 71ab9ae..aac0273 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -126,7 +126,10 @@ "VILLA": "Biệt thự", "LAND": "Đất nền", "OFFICE": "Văn phòng", - "SHOPHOUSE": "Shophouse" + "SHOPHOUSE": "Shophouse", + "ROOM_RENTAL": "Phòng trọ", + "CONDOTEL": "Condotel", + "SERVICED_APARTMENT": "Căn hộ dịch vụ" }, "transactionTypes": { "SALE": "Bán", diff --git a/prisma/migrations/20260422000000_add_usage_record_unique_constraint/migration.sql b/prisma/migrations/20260422000000_add_usage_record_unique_constraint/migration.sql new file mode 100644 index 0000000..d5a66b2 --- /dev/null +++ b/prisma/migrations/20260422000000_add_usage_record_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd"); diff --git a/prisma/migrations/20260422010000_add_room_rental_property_types/migration.sql b/prisma/migrations/20260422010000_add_room_rental_property_types/migration.sql new file mode 100644 index 0000000..cb7011a --- /dev/null +++ b/prisma/migrations/20260422010000_add_room_rental_property_types/migration.sql @@ -0,0 +1,7 @@ +-- AlterEnum +-- Add ROOM_RENTAL, CONDOTEL, and SERVICED_APARTMENT to the PropertyType enum. +-- These new values support phòng trọ (room rentals), condotels, and serviced apartment listings. + +ALTER TYPE "PropertyType" ADD VALUE 'ROOM_RENTAL'; +ALTER TYPE "PropertyType" ADD VALUE 'CONDOTEL'; +ALTER TYPE "PropertyType" ADD VALUE 'SERVICED_APARTMENT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 295f086..e022615 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -265,6 +265,9 @@ enum PropertyType { LAND OFFICE SHOPHOUSE + ROOM_RENTAL + CONDOTEL + SERVICED_APARTMENT } enum TransactionType { @@ -757,6 +760,7 @@ model UsageRecord { periodStart DateTime periodEnd DateTime + @@unique([subscriptionId, metric, periodStart, periodEnd]) @@index([subscriptionId, metric]) } @@ -1064,10 +1068,10 @@ model IndustrialPark { remainingAreaHa Float tenantCount Int @default(0) establishedYear Int? - landRentUsdM2Year Float? - rbfRentUsdM2Month Float? - rbwRentUsdM2Month Float? - managementFeeUsd Float? + landRentUsdM2Year Decimal? @db.Decimal(18, 4) + rbfRentUsdM2Month Decimal? @db.Decimal(18, 4) + rbwRentUsdM2Month Decimal? @db.Decimal(18, 4) + managementFeeUsd Decimal? @db.Decimal(18, 4) infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire } connectivity Json? // { nearestPort, airport, highway, railway, seaport } incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone } @@ -1121,10 +1125,10 @@ model IndustrialListing { hasMezzanine Boolean @default(false) hasOfficeArea Boolean @default(false) officeAreaM2 Float? - priceUsdM2 Float? + priceUsdM2 Decimal? @db.Decimal(18, 4) pricingUnit String? // "usd/m2/month", "usd/m2/year" - totalLeasePrice Float? - managementFee Float? + totalLeasePrice Decimal? @db.Decimal(18, 4) + managementFee Decimal? @db.Decimal(18, 4) depositMonths Int? minLeaseYears Int? maxLeaseYears Int?