feat: implement staff price adjustment feature for bookings

This commit is contained in:
Marshal
2026-06-23 22:54:32 +00:00
parent 196e296275
commit 2b4dfc6490
13 changed files with 364 additions and 89 deletions

View File

@@ -252,7 +252,8 @@ export class BookingPricingService {
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
// Coerce defensively in case the stored flag is a string ("true"/"false").
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,

View File

@@ -451,6 +451,30 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**

View File

@@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
@@ -455,6 +456,25 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -44,6 +44,22 @@ export class RejectBookingDto {
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()

View File

@@ -161,6 +161,22 @@ export class Booking extends BaseEntity {
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
/**
* Staff-adjusted total price. When set, it overrides the computed totalAmount
* for the customer, who is shown an "Adjusted by EDR" badge.
*/
@Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
adjustedTotalAmount?: number | null;
@Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true })
adjustedByStaffId?: string | null;
@Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true })
adjustedAt?: Date | null;
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string | null;
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
@@ -89,6 +89,8 @@ export interface RuleEvaluationResult {
@Injectable()
export class RuleEngineService {
private readonly logger = new Logger(RuleEngineService.name);
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepo: ICargoTypesRepository,
@@ -207,6 +209,14 @@ export class RuleEngineService {
const liveRates = await this.ratesRepo.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
// TEMP diagnostic — trace the surcharge trigger state so we can confirm
// whether a "Hazardous" line is firing for a non-hazardous booking.
this.logger.debug(
`surcharge eval: isHazardous=${input.isHazardous} (type ${typeof input.isHazardous}) ` +
`hasReefer=${hasReefer} hasOverweight=${hasOverweight} ` +
`shippingLineMapped=${shippingLineMapped}`,
);
for (const st of surchargeTypes) {
const triggered = this.matchesTrigger(st.triggerCondition, {
isHazardous: input.isHazardous,
@@ -233,6 +243,11 @@ export class RuleEngineService {
}
}
// Safety guard: never include a surcharge with a non-positive amount (a
// zero-rate or zero-trigger line would otherwise show as a confusing
// "free" surcharge on the breakdown).
if (!(calculatedAmount > 0)) continue;
appliedModifiers.push({
surchargeTypeId: st.id,
surchargeTypeCode: st.code,
@@ -382,17 +397,20 @@ export class RuleEngineService {
allowConsolidation: boolean;
},
): boolean {
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';
switch (condition) {
case 'CARGO_FLAG_HAZARDOUS':
return state.isHazardous;
return truthy(state.isHazardous);
case 'CARGO_FLAG_REEFER':
return state.hasReefer;
return truthy(state.hasReefer);
case 'VGM_EXCEEDS_LIMIT':
return state.hasOverweight;
return truthy(state.hasOverweight);
case 'SHIPPING_LINE_MAPPED':
return state.shippingLineMapped;
return truthy(state.shippingLineMapped);
case 'CONSOLIDATION_ENABLED':
return state.allowConsolidation;
return truthy(state.allowConsolidation);
default:
return false;
}