add pricing feature

This commit is contained in:
root
2026-06-02 01:15:04 -04:00
parent 05d7b1bf8c
commit 0df950f2b1
18 changed files with 2364 additions and 29 deletions
@@ -0,0 +1,107 @@
CREATE TYPE "VehiclePricingMode" AS ENUM ('MANUAL', 'AUTOMATIC');
CREATE TYPE "VehiclePricingRuleType" AS ENUM (
'DATE_RANGE',
'SEASONAL',
'HOLIDAY',
'WEEKEND',
'SPECIAL_EVENT',
'MANUAL_OVERRIDE'
);
CREATE TYPE "VehiclePriceChangeSource" AS ENUM (
'CONFIG_UPDATE',
'RULE_CREATED',
'RULE_UPDATED',
'RULE_DELETED',
'MANUAL_OVERRIDE'
);
CREATE TABLE "vehicle_pricing_configurations" (
"id" TEXT NOT NULL,
"vehicleId" TEXT NOT NULL,
"pricingMode" "VehiclePricingMode" NOT NULL DEFAULT 'MANUAL',
"baseDailyRate" INTEGER,
"weeklyRate" INTEGER,
"weekendRate" INTEGER,
"holidayRate" INTEGER,
"monthlyRate" INTEGER,
"longTermDailyRate" INTEGER,
"minimumDailyRate" INTEGER,
"maximumDailyRate" INTEGER,
"maxDailyPriceMovementPct" INTEGER NOT NULL DEFAULT 10,
"automaticPricingEnabled" BOOLEAN NOT NULL DEFAULT false,
"approvalRequired" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_pricing_configurations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "vehicle_pricing_configurations_vehicleId_key"
ON "vehicle_pricing_configurations"("vehicleId");
CREATE TABLE "vehicle_pricing_rules" (
"id" TEXT NOT NULL,
"configurationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ruleType" "VehiclePricingRuleType" NOT NULL DEFAULT 'DATE_RANGE',
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"dailyRate" INTEGER,
"weeklyRate" INTEGER,
"weekendRate" INTEGER,
"monthlyRate" INTEGER,
"minDailyRate" INTEGER,
"maxDailyRate" INTEGER,
"automaticAdjustmentPct" INTEGER,
"priceAdjustment" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_pricing_rules_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "vehicle_pricing_rules_configurationId_idx"
ON "vehicle_pricing_rules"("configurationId");
CREATE INDEX "vehicle_pricing_rules_configurationId_startDate_endDate_idx"
ON "vehicle_pricing_rules"("configurationId", "startDate", "endDate");
CREATE TABLE "vehicle_price_history" (
"id" TEXT NOT NULL,
"configurationId" TEXT NOT NULL,
"vehicleId" TEXT NOT NULL,
"source" "VehiclePriceChangeSource" NOT NULL DEFAULT 'CONFIG_UPDATE',
"changedByEmployeeId" TEXT,
"previousDailyRate" INTEGER,
"nextDailyRate" INTEGER,
"previousWeeklyRate" INTEGER,
"nextWeeklyRate" INTEGER,
"note" TEXT,
"effectiveFrom" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "vehicle_price_history_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "vehicle_price_history_vehicleId_createdAt_idx"
ON "vehicle_price_history"("vehicleId", "createdAt");
CREATE INDEX "vehicle_price_history_configurationId_createdAt_idx"
ON "vehicle_price_history"("configurationId", "createdAt");
ALTER TABLE "vehicle_pricing_configurations"
ADD CONSTRAINT "vehicle_pricing_configurations_vehicleId_fkey"
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_pricing_rules"
ADD CONSTRAINT "vehicle_pricing_rules_configurationId_fkey"
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_price_history"
ADD CONSTRAINT "vehicle_price_history_configurationId_fkey"
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vehicle_price_history"
ADD CONSTRAINT "vehicle_price_history_vehicleId_fkey"
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+98
View File
@@ -205,6 +205,28 @@ enum FuelType {
HYBRID
}
enum VehiclePricingMode {
MANUAL
AUTOMATIC
}
enum VehiclePricingRuleType {
DATE_RANGE
SEASONAL
HOLIDAY
WEEKEND
SPECIAL_EVENT
MANUAL_OVERRIDE
}
enum VehiclePriceChangeSource {
CONFIG_UPDATE
RULE_CREATED
RULE_UPDATED
RULE_DELETED
MANUAL_OVERRIDE
}
enum OfferType {
PERCENTAGE
FIXED_AMOUNT
@@ -996,6 +1018,8 @@ model Vehicle {
maintenance MaintenanceLog[]
offerVehicles OfferVehicle[]
calendarBlocks VehicleCalendarBlock[]
pricingConfiguration VehiclePricingConfiguration?
priceHistory VehiclePriceHistory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1006,6 +1030,80 @@ model Vehicle {
@@map("vehicles")
}
model VehiclePricingConfiguration {
id String @id @default(cuid())
vehicleId String @unique
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
pricingMode VehiclePricingMode @default(MANUAL)
baseDailyRate Int?
weeklyRate Int?
weekendRate Int?
holidayRate Int?
monthlyRate Int?
longTermDailyRate Int?
minimumDailyRate Int?
maximumDailyRate Int?
maxDailyPriceMovementPct Int @default(10)
automaticPricingEnabled Boolean @default(false)
approvalRequired Boolean @default(false)
rules VehiclePricingRule[]
history VehiclePriceHistory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("vehicle_pricing_configurations")
}
model VehiclePricingRule {
id String @id @default(cuid())
configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
name String
ruleType VehiclePricingRuleType @default(DATE_RANGE)
startDate DateTime
endDate DateTime
dailyRate Int?
weeklyRate Int?
weekendRate Int?
monthlyRate Int?
minDailyRate Int?
maxDailyRate Int?
automaticAdjustmentPct Int?
priceAdjustment Int?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([configurationId])
@@index([configurationId, startDate, endDate])
@@map("vehicle_pricing_rules")
}
model VehiclePriceHistory {
id String @id @default(cuid())
configurationId String
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
vehicleId String
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
changedByEmployeeId String?
previousDailyRate Int?
nextDailyRate Int?
previousWeeklyRate Int?
nextWeeklyRate Int?
note String?
effectiveFrom DateTime?
createdAt DateTime @default(now())
@@index([vehicleId, createdAt])
@@index([configurationId, createdAt])
@@map("vehicle_price_history")
}
model Offer {
id String @id @default(cuid())
companyId String
+10 -1
View File
@@ -3,7 +3,11 @@
const { spawnSync } = require('node:child_process')
const { readdirSync } = require('node:fs')
const path = require('node:path')
const { ensureDatabaseUrl } = require('../src/runtime-config')
const {
ensureDatabaseUrl,
loadDefaultEnvFiles,
normalizeDatabaseUrlForExecution,
} = require('../src/runtime-config')
const { PrismaClient } = require('../generated')
const repoRoot = path.resolve(__dirname, '../../..')
@@ -24,7 +28,9 @@ function run(command, args) {
}
async function main() {
loadDefaultEnvFiles(repoRoot)
ensureDatabaseUrl()
normalizeDatabaseUrlForExecution()
const prisma = new PrismaClient()
try {
@@ -103,6 +109,9 @@ async function main() {
}
main().catch((error) => {
if (error?.message?.includes("Can't reach database server at `localhost:5432`")) {
console.error('[db:deploy] Hint: start the local Postgres service first. In this repo that is usually `docker compose -f docker-compose.dev.yml up -d postgres`.')
}
console.error('[db:deploy] Failed:', error)
process.exit(1)
})
+94
View File
@@ -1,5 +1,96 @@
const fs = require('node:fs')
const path = require('node:path')
const ENABLED_VALUES = new Set(['1', 'true', 'yes'])
function parseEnvFile(content) {
const env = {}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line
const separatorIndex = normalized.indexOf('=')
if (separatorIndex === -1) continue
const key = normalized.slice(0, separatorIndex).trim()
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
let value = normalized.slice(separatorIndex + 1)
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
const quote = value[0]
value = value.slice(1, -1)
if (quote === '"') {
value = value
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
}
}
env[key] = value
}
return env
}
function loadEnvFile(filePath, env = process.env, { override = false } = {}) {
if (!fs.existsSync(filePath)) return false
const parsed = parseEnvFile(fs.readFileSync(filePath, 'utf8'))
for (const [key, value] of Object.entries(parsed)) {
if (override || env[key] === undefined) {
env[key] = value
}
}
return true
}
function isRunningInContainer() {
return fs.existsSync('/.dockerenv')
}
function normalizeDatabaseUrlForExecution(env = process.env) {
if (!env.DATABASE_URL) return env.DATABASE_URL
let parsed
try {
parsed = new URL(env.DATABASE_URL)
} catch {
return env.DATABASE_URL
}
if (parsed.hostname !== 'postgres' || isRunningInContainer()) {
return env.DATABASE_URL
}
const fallbackHost = env.POSTGRES_HOST_LOCAL ?? 'localhost'
parsed.hostname = fallbackHost
env.DATABASE_URL = parsed.toString()
return env.DATABASE_URL
}
function loadDefaultEnvFiles(baseDir, env = process.env) {
const candidates = [
path.join(baseDir, '.env'),
path.join(baseDir, '.env.local'),
path.join(baseDir, '.env.docker.dev'),
]
for (const candidate of candidates) {
loadEnvFile(candidate, env)
}
}
function ensureDatabaseUrl(env = process.env) {
const shouldBuildFromPostgres = ENABLED_VALUES.has(
env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '',
@@ -26,4 +117,7 @@ function ensureDatabaseUrl(env = process.env) {
module.exports = {
ensureDatabaseUrl,
loadDefaultEnvFiles,
loadEnvFile,
normalizeDatabaseUrlForExecution,
}