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;