add pricing feature
This commit is contained in:
@@ -89,3 +89,54 @@ export async function findMaintenanceLogs(vehicleId: string) {
|
||||
export async function createMaintenanceLog(data: any) {
|
||||
return prisma.maintenanceLog.create({ data })
|
||||
}
|
||||
|
||||
export async function findPricingConfiguration(vehicleId: string) {
|
||||
return prisma.vehiclePricingConfiguration.findUnique({
|
||||
where: { vehicleId },
|
||||
include: {
|
||||
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPricingConfiguration(data: any) {
|
||||
return prisma.vehiclePricingConfiguration.create({
|
||||
data,
|
||||
include: {
|
||||
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePricingConfiguration(id: string, data: any) {
|
||||
return prisma.vehiclePricingConfiguration.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findPricingRule(id: string, configurationId: string) {
|
||||
return prisma.vehiclePricingRule.findFirst({ where: { id, configurationId } })
|
||||
}
|
||||
|
||||
export async function createPricingRule(data: any) {
|
||||
return prisma.vehiclePricingRule.create({ data })
|
||||
}
|
||||
|
||||
export async function updatePricingRule(id: string, data: any) {
|
||||
return prisma.vehiclePricingRule.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
export async function deletePricingRule(id: string, configurationId: string) {
|
||||
return prisma.vehiclePricingRule.deleteMany({ where: { id, configurationId } })
|
||||
}
|
||||
|
||||
export async function createPriceHistory(data: any) {
|
||||
return prisma.vehiclePriceHistory.create({ data })
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import * as service from './vehicle.service'
|
||||
import {
|
||||
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
|
||||
calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema,
|
||||
idParamSchema, photoIdxSchema, blockIdParamSchema,
|
||||
pricingConfigSchema, pricingRuleSchema,
|
||||
idParamSchema, photoIdxSchema, blockIdParamSchema, pricingRuleParamSchema,
|
||||
} from './vehicle.schemas'
|
||||
|
||||
const router = Router()
|
||||
@@ -145,4 +146,42 @@ router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) =
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/:id/pricing', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await service.getVehiclePricing(id, req.companyId))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingConfigSchema, req)
|
||||
ok(res, await service.updateVehiclePricing(id, req.companyId, req.employee.id, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema, req)
|
||||
created(res, await service.createVehiclePricingRule(id, req.companyId, req.employee.id, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
|
||||
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||
ok(res, await service.updateVehiclePricingRule(id, req.companyId, ruleId, req.employee.id, body))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
|
||||
ok(res, await service.deleteVehiclePricingRule(id, req.companyId, ruleId, req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -12,7 +12,7 @@ export const vehicleSchema = z.object({
|
||||
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
|
||||
fuelType: z.enum(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']).default('GASOLINE'),
|
||||
features: z.array(z.string()).default([]),
|
||||
dailyRate: z.number().int().min(0),
|
||||
dailyRate: z.number().int().min(0).optional(),
|
||||
mileage: z.number().int().optional(),
|
||||
notes: z.string().optional(),
|
||||
isPublished: z.boolean().default(true),
|
||||
@@ -57,9 +57,45 @@ export const maintenanceLogSchema = z.object({
|
||||
nextDueMileage: z.number().int().optional(),
|
||||
})
|
||||
|
||||
const nullableRateSchema = z.number().int().min(0).nullable().optional()
|
||||
|
||||
export const pricingConfigSchema = z.object({
|
||||
pricingMode: z.enum(['MANUAL', 'AUTOMATIC']),
|
||||
baseDailyRate: z.number().int().min(0),
|
||||
weeklyRate: nullableRateSchema,
|
||||
weekendRate: nullableRateSchema,
|
||||
holidayRate: nullableRateSchema,
|
||||
monthlyRate: nullableRateSchema,
|
||||
longTermDailyRate: nullableRateSchema,
|
||||
minimumDailyRate: nullableRateSchema,
|
||||
maximumDailyRate: nullableRateSchema,
|
||||
maxDailyPriceMovementPct: z.number().int().min(0).max(100).default(10),
|
||||
automaticPricingEnabled: z.boolean().default(false),
|
||||
approvalRequired: z.boolean().default(false),
|
||||
note: z.string().trim().min(1).max(500).optional(),
|
||||
})
|
||||
|
||||
export const pricingRuleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
ruleType: z.enum(['DATE_RANGE', 'SEASONAL', 'HOLIDAY', 'WEEKEND', 'SPECIAL_EVENT', 'MANUAL_OVERRIDE']).default('DATE_RANGE'),
|
||||
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||
dailyRate: nullableRateSchema,
|
||||
weeklyRate: nullableRateSchema,
|
||||
weekendRate: nullableRateSchema,
|
||||
monthlyRate: nullableRateSchema,
|
||||
minDailyRate: nullableRateSchema,
|
||||
maxDailyRate: nullableRateSchema,
|
||||
automaticAdjustmentPct: z.number().int().min(-100).max(200).nullable().optional(),
|
||||
priceAdjustment: z.number().int().nullable().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
sortOrder: z.number().int().min(0).max(1000).default(0),
|
||||
})
|
||||
|
||||
export const publishSchema = z.object({ isPublished: z.boolean() })
|
||||
export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) })
|
||||
|
||||
export const idParamSchema = z.object({ id: z.string() })
|
||||
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
|
||||
export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() })
|
||||
export const pricingRuleParamSchema = z.object({ id: z.string(), ruleId: z.string() })
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||
import * as repo from './vehicle.repo'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RULE_PRIORITY: Record<string, number> = {
|
||||
MANUAL_OVERRIDE: 6,
|
||||
HOLIDAY: 5,
|
||||
SPECIAL_EVENT: 4,
|
||||
SEASONAL: 3,
|
||||
WEEKEND: 2,
|
||||
DATE_RANGE: 1,
|
||||
}
|
||||
|
||||
function isPricingStorageMissing(error: unknown) {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const candidate = error as { code?: string; message?: string }
|
||||
return (
|
||||
candidate.code === 'P2021' ||
|
||||
candidate.message?.includes('vehicle_pricing_configurations') === true ||
|
||||
candidate.message?.includes('vehicle_pricing_rules') === true ||
|
||||
candidate.message?.includes('vehicle_price_history') === true
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeLocations(locations: unknown) {
|
||||
if (!Array.isArray(locations)) return []
|
||||
const seen = new Set<string>()
|
||||
@@ -39,6 +60,291 @@ function applyLocationSettings(data: any) {
|
||||
return patch
|
||||
}
|
||||
|
||||
function toStartOfDay(value: string | Date) {
|
||||
const date = new Date(value)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}
|
||||
|
||||
function toEndOfDay(value: string | Date) {
|
||||
const date = new Date(value)
|
||||
date.setHours(23, 59, 59, 999)
|
||||
return date
|
||||
}
|
||||
|
||||
function isWeekend(date: Date) {
|
||||
const day = date.getDay()
|
||||
return day === 0 || day === 6
|
||||
}
|
||||
|
||||
function clampRate(value: number, min?: number | null, max?: number | null) {
|
||||
if (min != null && value < min) return min
|
||||
if (max != null && value > max) return max
|
||||
return value
|
||||
}
|
||||
|
||||
function validateRateBounds(minimum?: number | null, maximum?: number | null, label: string = 'Daily') {
|
||||
if (minimum != null && maximum != null && minimum > maximum) {
|
||||
throw new ValidationError(`${label} minimum rate must be less than or equal to the maximum rate`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePricingConfigurationInput(data: any) {
|
||||
const normalized = {
|
||||
pricingMode: data.pricingMode,
|
||||
baseDailyRate: data.baseDailyRate,
|
||||
weeklyRate: data.weeklyRate ?? null,
|
||||
weekendRate: data.weekendRate ?? null,
|
||||
holidayRate: data.holidayRate ?? null,
|
||||
monthlyRate: data.monthlyRate ?? null,
|
||||
longTermDailyRate: data.longTermDailyRate ?? null,
|
||||
minimumDailyRate: data.minimumDailyRate ?? null,
|
||||
maximumDailyRate: data.maximumDailyRate ?? null,
|
||||
maxDailyPriceMovementPct: data.maxDailyPriceMovementPct,
|
||||
automaticPricingEnabled: data.automaticPricingEnabled,
|
||||
approvalRequired: data.approvalRequired,
|
||||
}
|
||||
|
||||
validateRateBounds(normalized.minimumDailyRate, normalized.maximumDailyRate)
|
||||
validateRateBounds(normalized.baseDailyRate, normalized.maximumDailyRate, 'Base')
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizePricingRuleInput(data: any) {
|
||||
const normalized = {
|
||||
name: data.name,
|
||||
ruleType: data.ruleType,
|
||||
startDate: toStartOfDay(data.startDate),
|
||||
endDate: toEndOfDay(data.endDate),
|
||||
dailyRate: data.dailyRate ?? null,
|
||||
weeklyRate: data.weeklyRate ?? null,
|
||||
weekendRate: data.weekendRate ?? null,
|
||||
monthlyRate: data.monthlyRate ?? null,
|
||||
minDailyRate: data.minDailyRate ?? null,
|
||||
maxDailyRate: data.maxDailyRate ?? null,
|
||||
automaticAdjustmentPct: data.automaticAdjustmentPct ?? null,
|
||||
priceAdjustment: data.priceAdjustment ?? null,
|
||||
isActive: data.isActive,
|
||||
sortOrder: data.sortOrder,
|
||||
}
|
||||
|
||||
if (normalized.endDate < normalized.startDate) {
|
||||
throw new ValidationError('Rule end date must be on or after the start date')
|
||||
}
|
||||
|
||||
validateRateBounds(normalized.minDailyRate, normalized.maxDailyRate)
|
||||
|
||||
const hasPricingEffect = [
|
||||
normalized.dailyRate,
|
||||
normalized.weeklyRate,
|
||||
normalized.weekendRate,
|
||||
normalized.monthlyRate,
|
||||
normalized.minDailyRate,
|
||||
normalized.maxDailyRate,
|
||||
normalized.automaticAdjustmentPct,
|
||||
normalized.priceAdjustment,
|
||||
].some((value) => value != null)
|
||||
|
||||
if (!hasPricingEffect) {
|
||||
throw new ValidationError('Pricing rules must define at least one rate, bound, or adjustment')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function ensurePricingConfigurationForVehicle(vehicleId: string, companyId: string) {
|
||||
const vehicle = await repo.findById(vehicleId, companyId)
|
||||
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
|
||||
let configuration: any = null
|
||||
let persistenceAvailable = true
|
||||
try {
|
||||
configuration = await repo.findPricingConfiguration(vehicleId)
|
||||
} catch (error) {
|
||||
if (!isPricingStorageMissing(error)) throw error
|
||||
persistenceAvailable = false
|
||||
}
|
||||
|
||||
if (!persistenceAvailable) {
|
||||
return {
|
||||
vehicle,
|
||||
persistenceAvailable,
|
||||
configuration: {
|
||||
id: `transient-${vehicle.id}`,
|
||||
vehicleId: vehicle.id,
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: vehicle.dailyRate,
|
||||
weeklyRate: vehicle.dailyRate * 7,
|
||||
weekendRate: null,
|
||||
holidayRate: null,
|
||||
monthlyRate: null,
|
||||
longTermDailyRate: null,
|
||||
minimumDailyRate: null,
|
||||
maximumDailyRate: null,
|
||||
maxDailyPriceMovementPct: 10,
|
||||
automaticPricingEnabled: false,
|
||||
approvalRequired: false,
|
||||
rules: [],
|
||||
history: [],
|
||||
createdAt: vehicle.createdAt,
|
||||
updatedAt: vehicle.updatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!configuration) {
|
||||
configuration = await repo.createPricingConfiguration({
|
||||
vehicleId,
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: vehicle.dailyRate,
|
||||
weeklyRate: vehicle.dailyRate * 7,
|
||||
automaticPricingEnabled: false,
|
||||
approvalRequired: false,
|
||||
maxDailyPriceMovementPct: 10,
|
||||
})
|
||||
}
|
||||
|
||||
return { vehicle, configuration, persistenceAvailable }
|
||||
}
|
||||
|
||||
async function syncPricingBaseRateFromVehicle(vehicleId: string, previousDailyRate: number, nextDailyRate: number) {
|
||||
const configuration = await repo.findPricingConfiguration(vehicleId)
|
||||
if (!configuration) return
|
||||
|
||||
const currentBaseRate = configuration.baseDailyRate ?? previousDailyRate
|
||||
if (currentBaseRate === nextDailyRate) return
|
||||
|
||||
await repo.updatePricingConfiguration(configuration.id, { baseDailyRate: nextDailyRate })
|
||||
await repo.createPriceHistory({
|
||||
configurationId: configuration.id,
|
||||
vehicleId,
|
||||
source: 'CONFIG_UPDATE',
|
||||
previousDailyRate: currentBaseRate,
|
||||
nextDailyRate,
|
||||
previousWeeklyRate: configuration.weeklyRate ?? null,
|
||||
nextWeeklyRate: configuration.weeklyRate ?? null,
|
||||
note: 'Base daily rate synced from vehicle details',
|
||||
})
|
||||
}
|
||||
|
||||
function buildPricePreview(vehicle: any, configuration: any) {
|
||||
const baseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate
|
||||
const activeRules = (configuration.rules ?? []).filter((rule: any) => rule.isActive)
|
||||
const items = Array.from({ length: 14 }, (_, index) => {
|
||||
const date = new Date(Date.now() + (index * DAY_MS))
|
||||
date.setHours(12, 0, 0, 0)
|
||||
let dailyRate = baseDailyRate
|
||||
let pricingType = configuration.pricingMode === 'AUTOMATIC' && configuration.automaticPricingEnabled
|
||||
? 'AUTOMATIC_BASE'
|
||||
: 'MANUAL_BASE'
|
||||
|
||||
if (isWeekend(date) && configuration.weekendRate != null) {
|
||||
dailyRate = configuration.weekendRate
|
||||
pricingType = 'WEEKEND_RATE'
|
||||
}
|
||||
|
||||
const matchingRule = activeRules
|
||||
.filter((rule: any) => date >= new Date(rule.startDate) && date <= new Date(rule.endDate))
|
||||
.sort((a: any, b: any) => {
|
||||
const priorityDiff = (RULE_PRIORITY[b.ruleType] ?? 0) - (RULE_PRIORITY[a.ruleType] ?? 0)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
if (a.sortOrder !== b.sortOrder) return b.sortOrder - a.sortOrder
|
||||
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
||||
})[0]
|
||||
|
||||
if (matchingRule) {
|
||||
if (matchingRule.dailyRate != null) {
|
||||
dailyRate = matchingRule.dailyRate
|
||||
} else {
|
||||
if (matchingRule.weekendRate != null && isWeekend(date)) {
|
||||
dailyRate = matchingRule.weekendRate
|
||||
}
|
||||
if (matchingRule.priceAdjustment != null) {
|
||||
dailyRate += matchingRule.priceAdjustment
|
||||
}
|
||||
if (matchingRule.automaticAdjustmentPct != null) {
|
||||
dailyRate = Math.round(dailyRate * (100 + matchingRule.automaticAdjustmentPct) / 100)
|
||||
}
|
||||
}
|
||||
pricingType = configuration.pricingMode === 'AUTOMATIC' ? 'AUTOMATIC_RULE' : 'RULE_RATE'
|
||||
dailyRate = clampRate(
|
||||
dailyRate,
|
||||
matchingRule.minDailyRate ?? configuration.minimumDailyRate,
|
||||
matchingRule.maxDailyRate ?? configuration.maximumDailyRate,
|
||||
)
|
||||
} else {
|
||||
dailyRate = clampRate(dailyRate, configuration.minimumDailyRate, configuration.maximumDailyRate)
|
||||
}
|
||||
|
||||
return {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
dailyRate,
|
||||
pricingType,
|
||||
matchedRuleName: matchingRule?.name ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
today: items[0],
|
||||
next14Days: items,
|
||||
}
|
||||
}
|
||||
|
||||
function presentVehiclePricing(vehicle: any, configuration: any) {
|
||||
return {
|
||||
configuration: {
|
||||
id: configuration.id,
|
||||
vehicleId: configuration.vehicleId,
|
||||
pricingMode: configuration.pricingMode,
|
||||
baseDailyRate: configuration.baseDailyRate ?? vehicle.dailyRate,
|
||||
weeklyRate: configuration.weeklyRate ?? null,
|
||||
weekendRate: configuration.weekendRate ?? null,
|
||||
holidayRate: configuration.holidayRate ?? null,
|
||||
monthlyRate: configuration.monthlyRate ?? null,
|
||||
longTermDailyRate: configuration.longTermDailyRate ?? null,
|
||||
minimumDailyRate: configuration.minimumDailyRate ?? null,
|
||||
maximumDailyRate: configuration.maximumDailyRate ?? null,
|
||||
maxDailyPriceMovementPct: configuration.maxDailyPriceMovementPct,
|
||||
automaticPricingEnabled: configuration.automaticPricingEnabled,
|
||||
approvalRequired: configuration.approvalRequired,
|
||||
createdAt: configuration.createdAt,
|
||||
updatedAt: configuration.updatedAt,
|
||||
},
|
||||
rules: (configuration.rules ?? []).map((rule: any) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
startDate: rule.startDate,
|
||||
endDate: rule.endDate,
|
||||
dailyRate: rule.dailyRate,
|
||||
weeklyRate: rule.weeklyRate,
|
||||
weekendRate: rule.weekendRate,
|
||||
monthlyRate: rule.monthlyRate,
|
||||
minDailyRate: rule.minDailyRate,
|
||||
maxDailyRate: rule.maxDailyRate,
|
||||
automaticAdjustmentPct: rule.automaticAdjustmentPct,
|
||||
priceAdjustment: rule.priceAdjustment,
|
||||
isActive: rule.isActive,
|
||||
sortOrder: rule.sortOrder,
|
||||
createdAt: rule.createdAt,
|
||||
updatedAt: rule.updatedAt,
|
||||
})),
|
||||
history: (configuration.history ?? []).map((entry: any) => ({
|
||||
id: entry.id,
|
||||
source: entry.source,
|
||||
changedByEmployeeId: entry.changedByEmployeeId,
|
||||
previousDailyRate: entry.previousDailyRate,
|
||||
nextDailyRate: entry.nextDailyRate,
|
||||
previousWeeklyRate: entry.previousWeeklyRate,
|
||||
nextWeeklyRate: entry.nextWeeklyRate,
|
||||
note: entry.note,
|
||||
effectiveFrom: entry.effectiveFrom,
|
||||
createdAt: entry.createdAt,
|
||||
})),
|
||||
preview: buildPricePreview(vehicle, configuration),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1
|
||||
const pageSize = query.pageSize ?? 20
|
||||
@@ -58,7 +364,11 @@ export async function getVehicle(id: string, companyId: string) {
|
||||
}
|
||||
|
||||
export async function createVehicle(data: any, companyId: string) {
|
||||
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
|
||||
return presentVehicle(await repo.create({
|
||||
dailyRate: 0,
|
||||
...applyLocationSettings(data),
|
||||
companyId,
|
||||
}))
|
||||
}
|
||||
|
||||
const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
|
||||
@@ -70,7 +380,11 @@ export async function updateVehicle(id: string, companyId: string, data: any) {
|
||||
}
|
||||
const existing = await repo.findFirst(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Vehicle not found')
|
||||
return presentVehicle(await repo.updateById(id, patch))
|
||||
const updated = await repo.updateById(id, patch)
|
||||
if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') {
|
||||
await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate)
|
||||
}
|
||||
return presentVehicle(updated)
|
||||
}
|
||||
|
||||
export async function setStatus(id: string, companyId: string, status: string) {
|
||||
@@ -169,3 +483,156 @@ export async function createMaintenanceLog(id: string, companyId: string, data:
|
||||
nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getVehiclePricing(id: string, companyId: string) {
|
||||
const { vehicle, configuration } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||
return presentVehiclePricing(vehicle, configuration)
|
||||
}
|
||||
|
||||
export async function updateVehiclePricing(id: string, companyId: string, employeeId: string, data: any) {
|
||||
const { vehicle, configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||
if (!persistenceAvailable) {
|
||||
throw new AppError(
|
||||
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||
503,
|
||||
'pricing_migration_required',
|
||||
)
|
||||
}
|
||||
const nextConfig = normalizePricingConfigurationInput(data)
|
||||
const currentBaseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate
|
||||
const currentWeeklyRate = configuration.weeklyRate ?? null
|
||||
|
||||
const changed = [
|
||||
'pricingMode',
|
||||
'baseDailyRate',
|
||||
'weeklyRate',
|
||||
'weekendRate',
|
||||
'holidayRate',
|
||||
'monthlyRate',
|
||||
'longTermDailyRate',
|
||||
'minimumDailyRate',
|
||||
'maximumDailyRate',
|
||||
'maxDailyPriceMovementPct',
|
||||
'automaticPricingEnabled',
|
||||
'approvalRequired',
|
||||
].some((key) => ((configuration as any)[key] ?? null) !== ((nextConfig as any)[key] ?? null))
|
||||
|
||||
await repo.updatePricingConfiguration(configuration.id, nextConfig)
|
||||
|
||||
if (vehicle.dailyRate !== nextConfig.baseDailyRate) {
|
||||
await repo.updateById(id, { dailyRate: nextConfig.baseDailyRate })
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await repo.createPriceHistory({
|
||||
configurationId: configuration.id,
|
||||
vehicleId: id,
|
||||
source: 'CONFIG_UPDATE',
|
||||
changedByEmployeeId: employeeId,
|
||||
previousDailyRate: currentBaseDailyRate,
|
||||
nextDailyRate: nextConfig.baseDailyRate,
|
||||
previousWeeklyRate: currentWeeklyRate,
|
||||
nextWeeklyRate: nextConfig.weeklyRate ?? null,
|
||||
note: data.note,
|
||||
})
|
||||
}
|
||||
|
||||
return getVehiclePricing(id, companyId)
|
||||
}
|
||||
|
||||
export async function createVehiclePricingRule(id: string, companyId: string, employeeId: string, data: any) {
|
||||
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||
if (!persistenceAvailable) {
|
||||
throw new AppError(
|
||||
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||
503,
|
||||
'pricing_migration_required',
|
||||
)
|
||||
}
|
||||
const payload = normalizePricingRuleInput(data)
|
||||
|
||||
const rule = await repo.createPricingRule({
|
||||
configurationId: configuration.id,
|
||||
...payload,
|
||||
})
|
||||
|
||||
await repo.createPriceHistory({
|
||||
configurationId: configuration.id,
|
||||
vehicleId: id,
|
||||
source: payload.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_CREATED',
|
||||
changedByEmployeeId: employeeId,
|
||||
previousDailyRate: configuration.baseDailyRate ?? null,
|
||||
nextDailyRate: payload.dailyRate,
|
||||
previousWeeklyRate: configuration.weeklyRate ?? null,
|
||||
nextWeeklyRate: payload.weeklyRate,
|
||||
note: `Created pricing rule: ${rule.name}`,
|
||||
effectiveFrom: rule.startDate,
|
||||
})
|
||||
|
||||
return getVehiclePricing(id, companyId)
|
||||
}
|
||||
|
||||
export async function updateVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string, data: any) {
|
||||
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||
if (!persistenceAvailable) {
|
||||
throw new AppError(
|
||||
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||
503,
|
||||
'pricing_migration_required',
|
||||
)
|
||||
}
|
||||
const existingRule = await repo.findPricingRule(ruleId, configuration.id)
|
||||
if (!existingRule) throw new NotFoundError('Pricing rule not found')
|
||||
|
||||
const mergedRule = normalizePricingRuleInput({
|
||||
...existingRule,
|
||||
...data,
|
||||
startDate: data.startDate ?? existingRule.startDate,
|
||||
endDate: data.endDate ?? existingRule.endDate,
|
||||
})
|
||||
|
||||
const updatedRule = await repo.updatePricingRule(ruleId, mergedRule)
|
||||
await repo.createPriceHistory({
|
||||
configurationId: configuration.id,
|
||||
vehicleId: id,
|
||||
source: mergedRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_UPDATED',
|
||||
changedByEmployeeId: employeeId,
|
||||
previousDailyRate: existingRule.dailyRate,
|
||||
nextDailyRate: updatedRule.dailyRate,
|
||||
previousWeeklyRate: existingRule.weeklyRate,
|
||||
nextWeeklyRate: updatedRule.weeklyRate,
|
||||
note: `Updated pricing rule: ${updatedRule.name}`,
|
||||
effectiveFrom: updatedRule.startDate,
|
||||
})
|
||||
|
||||
return getVehiclePricing(id, companyId)
|
||||
}
|
||||
|
||||
export async function deleteVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string) {
|
||||
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||
if (!persistenceAvailable) {
|
||||
throw new AppError(
|
||||
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||
503,
|
||||
'pricing_migration_required',
|
||||
)
|
||||
}
|
||||
const existingRule = await repo.findPricingRule(ruleId, configuration.id)
|
||||
if (!existingRule) throw new NotFoundError('Pricing rule not found')
|
||||
|
||||
await repo.deletePricingRule(ruleId, configuration.id)
|
||||
await repo.createPriceHistory({
|
||||
configurationId: configuration.id,
|
||||
vehicleId: id,
|
||||
source: existingRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_DELETED',
|
||||
changedByEmployeeId: employeeId,
|
||||
previousDailyRate: existingRule.dailyRate,
|
||||
nextDailyRate: configuration.baseDailyRate ?? null,
|
||||
previousWeeklyRate: existingRule.weeklyRate,
|
||||
nextWeeklyRate: configuration.weeklyRate ?? null,
|
||||
note: `Deleted pricing rule: ${existingRule.name}`,
|
||||
effectiveFrom: existingRule.startDate,
|
||||
})
|
||||
|
||||
return getVehiclePricing(id, companyId)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ describe('vehicle.service', () => {
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
||||
expect(result).toEqual(mockVehicle)
|
||||
})
|
||||
|
||||
it('defaults dailyRate to zero when omitted', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ ...mockVehicle, dailyRate: 0 } as any)
|
||||
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateVehicle', () => {
|
||||
|
||||
Reference in New Issue
Block a user