Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
Marshal d5a5085d6d feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component.
- Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel.
- Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains.
- Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages.
- Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking.
- Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings.
- Added a new reference field to the audit logs for better searchability and tracking of actions.
- Created a migration to add the reference column to the audit logs table and established an index for efficient querying.
- Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
2026-08-24 23:49:20 +00:00

2209 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Request,
Res,
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import {
BookingStaff,
BookingView,
MixedAudience,
PortalCustomer,
WagonCancellationView,
} from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AnyFilesInterceptor, FileInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingClearanceChargeService } from "./booking-clearance-charge.service";
import { BookingPayablesService } from "./booking-payables.service";
import { ClearanceEventService } from "./clearance-event.service";
import { RejectClearanceChargeDto } from "./dto/clearance-charge.dto";
import { BillClearanceChargeDto } from "./dto/clearance-charge.dto";
import { AdditionalChargeService } from "./additional-charge.service";
import {
CancelAdditionalChargeDto,
CreateAdditionalChargeDto,
} from "./dto/additional-charge.dto";
import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from "./booking-pricing.service";
import { BookingTransitionService } from "./booking-transition.service";
import { BookingClearanceService } from "../contracts/booking-clearance.service";
import {
AdviseContractDutyDto,
RoAmendmentDto,
} from "../contracts/dto/phased-clearance.dto";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { scopedDirections } from "../user-trade-access/trade-scope.util";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BookingsService } from "./bookings.service";
import { ConsolidationApprovalService } from "./consolidation-approval.service";
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import {
AcceptIntakeDto,
ApproveConsolidationDto,
CancelBookingDto,
PairedDecisionDto,
RejectConsolidationDto,
RejectBookingDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
import { CustomerTruckService } from "./customer-truck.service";
import { FirstMileService } from "../first-mile/first-mile.service";
import { LastMileService } from "../last-mile/last-mile.service";
import { GenerateGrnDto } from "./dto/generate-grn.dto";
import { ContainerReceiptService } from "./container-receipt.service";
import { SignContractDto } from "./dto/sign-contract.dto";
import { SetExportHandoverModeDto } from "./dto/set-export-handover-mode.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { BookingWagonCancellationService } from "./booking-wagon-cancellation.service";
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from "./dto/wagon-cancellation.dto";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
/** Trim a first/last-mile record down to a customer-safe operational summary. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
if (!rec) return null;
const num = (v: unknown) => (v == null ? null : Number(v));
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
const currency =
rec.vehicle?.currency ??
assignments[0]?.vehicle?.currency ??
rec.booking?.paymentCurrency ??
"ETB";
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
plate: a.vehicle?.plateNumber ?? null,
code: a.vehicle?.code ?? null,
driverName: a.vehicle?.assignedDriverName ?? null,
containerNumber: a.containerNumber ?? null,
distanceKm: num(a.distanceKm),
}));
if (!vehicles.length && rec.vehicle) {
vehicles.push({
plate: rec.vehicle.plateNumber ?? null,
code: rec.vehicle.code ?? null,
driverName: rec.vehicle.assignedDriverName ?? null,
containerNumber: null,
distanceKm: num(rec.exactKm),
});
}
return {
status: rec.status ?? "",
exactKm: num(rec.exactKm),
remainingPayment: num(rec.remainingPayment),
currency,
invoiced: Boolean(rec.invoice),
vehicles,
};
}
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
export class BookingsController {
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly bookingPayablesService: BookingPayablesService,
private readonly clearanceEventService: ClearanceEventService,
private readonly additionalChargeService: AdditionalChargeService,
) {}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@ApiBody({ type: CreateBookingDto })
async create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(
dto,
files ?? [],
user?.id,
);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.bookings.staffAccept,
);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(
result.booking.id,
);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
return result;
}
}
return result;
}
@Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Update booking",
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
@Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
// Per-user trade-direction scope (import/export/intercity checkboxes).
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
const dirs = scopedDirections(allowed, filter.tradeDirection);
return this.bookingsService.findAll(
filter,
undefined,
undefined,
dirs ?? undefined,
);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException("Authentication required");
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
// Company-wide by default; the optional filter.companyProfileId (per-page
// service filter) narrows within the company. The company guard always
// applies, so a customer can only ever see their own company's bookings.
return this.bookingsService.findAll(filter, companyId);
}
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
// — otherwise a staffer granted only the customer permission gets a page whose
// tabs 403 individually.
@Get("by-company/:companyId/customer-view")
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
findByCompanyCustomerView(
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get("list-summary")
@BookingView()
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get("my-payables")
@PortalCustomer()
@ApiOperation({
summary:
"Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload",
})
async findMyPayables(@CurrentUser() user: AuthUserPayload) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(
resolveAuthUserId(user),
);
return companyId
? this.bookingPayablesService.summarizeForCompany(companyId)
: [];
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
"Bookings owned by the authenticated user's company that are payable " +
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@Query() filter: FilterBookingDto,
) {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get("queues/:queue")
@BookingView()
@ApiOperation({
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
})
findQueue(
@Param("queue") queue: string,
@Query() filter: FilterBookingDto,
@Query("excludeBulk") excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === "true",
});
}
@Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get("by-reference/:reference")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param("reference") reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/available-days")
@MixedAudience([])
@ApiOperation({
summary:
"Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)",
})
async availableDays(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.availableDaysForBooking(id);
}
@Get(":id/day-availability")
@MixedAudience([])
@ApiOperation({
summary:
"Advisory free-wagon count for a shipment day (planning hint, not enforced). " +
"Export: whole-booking fit + largest single-train leftover. " +
"Import/domestic: total room across the day for the booking's wagon type.",
})
async dayAvailability(
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.dayAvailabilityForBooking(id, date);
}
@Get(":id/mile-summary")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"First/last-mile operational summary for a booking (customer-safe)",
})
async mileSummary(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Customers may only see their own booking's mile summary.
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const [first, last] = await Promise.all([
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
]);
return {
firstMile: summarizeMileLeg(first.data[0]),
lastMile: summarizeMileLeg(last.data[0]),
};
}
@Post(":id/customer-truck-assignment")
@PortalCustomer()
@ApiOperation({
summary: "Customer assigns external truck and driver for terminal pickup",
})
async assignCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CustomerTruckAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
return this.transitionService.enrichBookingResponse(assigned);
}
@Get(":id/customer-truck-assignment/freight-order")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).",
})
async customerTruckFreightOrder(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
@Query("copies") copies?: string,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const extraCopyIndexes = (copies ?? "")
.split(",")
.map((n) => Number(n.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(
id,
extraCopyIndexes,
);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(":id/carriage-acceptance-sheet")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Download the carriage acceptance sheet (one per booking, lists every allocated wagon)",
})
async carriageAcceptanceSheet(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const { filename, buffer } =
await this.bookingsService.carriageAcceptanceSheet(id);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(":id/wagons")
@ApiOperation({
summary:
"Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train",
})
async wagonAllocations(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(":id/wagon-cancellations/preview")
@ApiOperation({
summary:
"Preview the fee/credit of a partial wagon cancellation (no writes)",
})
async previewWagonCancellation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(":id/wagon-cancellations")
@ApiOperation({
summary:
"Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles",
})
async requestWagonCancellation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(":id/wagon-cancellations")
@ApiOperation({
summary: "Wagon-cancellation history of one booking (owner or staff)",
})
async listBookingWagonCancellations(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get("wagon-cancellations/my")
@ApiOperation({
summary:
"Wagon-cancellation history of the calling customer (paginated, filterable)",
})
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(
user?.id ?? "",
);
if (!companyId)
throw new ForbiddenException("No customer company for this user.");
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get("wagon-cancellations/history")
@WagonCancellationView()
@ApiOperation({
summary: "All wagon cancellations (staff, paginated, filterable)",
})
async listAllWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post("wagon-cancellations/:cancellationId/withdraw")
@ApiOperation({
summary:
"Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.",
})
async withdrawWagonCancellation(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
// Customer cancellations are irreversible from the portal — no owner
// fallback here. Only staff holding the void permission can revert one.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)
) {
throw new ForbiddenException(
"A cancellation request cannot be withdrawn from the portal — contact EDR staff.",
);
}
return this.wagonCancellationService.withdraw(cancellationId);
}
@Get("wagon-cancellations/:cancellationId/rebook-partners")
@ApiOperation({
summary:
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
})
async listRebookPartners(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@Query("scheduledDate") scheduledDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
if (!scheduledDate) {
throw new BadRequestException("scheduledDate is required.");
}
return this.wagonCancellationService.rebookPartnerCandidates(
cancellationId,
scheduledDate,
);
}
@Post("wagon-cancellations/:cancellationId/rebook")
@ApiOperation({
summary:
"Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)",
})
async rebookWagonCancellation(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
@Get(":id/customer-trucks")
@MixedAudience([
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.warehouseInventory.view,
])
@ApiOperation({
summary: "List customer self-haul trucks (multi-truck) for a booking",
})
async listCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.listTrucks(id);
}
@Post(":id/customer-trucks")
@PortalCustomer()
@ApiOperation({
summary:
"Add a customer self-haul truck carrying 12 of the booking containers",
})
async addCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AddCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.addTruck(id, dto);
}
@Post(":id/customer-trucks/bulk")
@PortalCustomer()
@ApiOperation({
summary: "Bulk add customer trucks from array payload (Excel parsed)",
})
async bulkAddCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
}
@Patch(":id/customer-trucks/:assignmentId")
@PortalCustomer()
@ApiOperation({
summary:
"Edit a not-yet-arrived customer truck (plate/driver/type + containers)",
})
async updateCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Param("assignmentId", ParseUUIDPipe) assignmentId: string,
@Body() dto: AddCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.updateTruck(id, assignmentId, dto);
}
@Delete(":id/customer-trucks/:assignmentId")
@PortalCustomer()
@ApiOperation({
summary: "Remove a not-yet-arrived customer truck from a booking",
})
async removeCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Param("assignmentId", ParseUUIDPipe) assignmentId: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.removeTruck(id, assignmentId);
}
@Get(":id/customer-trucks/loadable-containers")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Booking containers not yet loaded onto a truck" })
async loadableContainers(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.customerTruckService.getLoadableContainers(id);
}
@Patch(":id/export-handover-mode")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first",
})
setExportHandoverMode(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SetExportHandoverModeDto,
) {
return this.bookingsService.setExportHandoverMode(
id,
dto.exportHandoverMode,
);
}
@Post(":id/customer-trucks/:assignmentId/load")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: "Truck_dispatch: load selected containers onto a truck (staff)",
})
async loadCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Param("assignmentId", ParseUUIDPipe) assignmentId: string,
@Body() dto: LoadCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException("Only warehouse staff can load a truck");
}
return this.customerTruckService.loadTruck(id, assignmentId, dto);
}
@Post(":id/customer-trucks/:assignmentId/depart")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Register an import truck leaving: containers loaded + weighed gross (staff)",
})
async departCustomerTruck(
@Param("id", ParseUUIDPipe) id: string,
@Param("assignmentId", ParseUUIDPipe) assignmentId: string,
@Body() dto: DepartCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
// Weighing + registering the load on exit is a warehouse/gate staff action.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException(
"Only warehouse staff can register a truck departure",
);
}
return this.customerTruckService.departTruck(id, assignmentId, dto);
}
@Get(":id/received-pending-grn")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Containers received into port but not yet on a GRN",
})
async receivedPendingGrn(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException(
"Only warehouse staff can view or generate GRNs",
);
}
return this.containerReceiptService.listReceivedPendingGrn(id);
}
@Post(":id/generate-grn")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Generate a GRN over the received containers (all received, or a subset) — one GRN per batch",
})
async generateGrn(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: GenerateGrnDto,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException(
"Only warehouse staff can view or generate GRNs",
);
}
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
}
@Get(":id/tracking")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
"tracking events. Scoped to the customer's own company.",
})
async findTracking(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.getBookingTracking(id);
}
@Delete(":id")
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(":id/documents")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
async uploadDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(":id/submit")
@MixedAudience([])
@ApiOperation({
summary: "Customer submit booking",
description:
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({
summary: "Confirm submit after price change",
description:
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(":id/reject")
@PortalCustomer()
@ApiOperation({
summary: "Customer reject price estimate",
description:
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
})
async reject(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
// ── Document clearance (post counter-sign) ────────────────────────────────
@Get("clearance/et-queue")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary: "GL ET queue — general customs bookings awaiting ET action",
})
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
return this.bookingClearanceService.etQueue(user);
}
@Get("clearance/dj-queue")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary: "GL DJ queue — general customs bookings awaiting DJ action",
})
getBookingDjClearanceQueue() {
return this.bookingClearanceService.djQueue();
}
@Get(":id/clearance")
@MixedAudience([
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
])
@ApiOperation({
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Customer uploads clearance documents (fieldname = document key)",
})
async submitClearanceDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
// Customer requests the operation; GL ET also resubmits here on the
// customer's behalf after operations requests changes (BookingChangesRequestedAlert).
@Post(":id/clearance/proceed")
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
"Customer requests operation with a schedule day " +
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async proceedToOperation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
{ userId: resolveAuthUserId(user) },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/export-trains")
@MixedAudience([])
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
"with per-wagon-type free space (export rail bookings only)",
})
async exportTrainsForBooking(
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date: string,
// Bare contract instances carry no cargo yet — the completion form sends
// what the customer is entering so per-type space reflects THEIR cargo.
@Query("containerTypeIds") containerTypeIds?: string,
@Query("containerSizes") containerSizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("cargoTypeCode") cargoTypeCode?: string,
@Query("wagons") wagons?: string,
) {
const parsedWagons = Number(wagons);
return this.transitionService.exportTrainsForBooking(id, date, {
containerTypeIds: containerTypeIds
? containerTypeIds.split(",").filter(Boolean)
: undefined,
containerSizes: containerSizes
? containerSizes.split(",").filter(Boolean)
: undefined,
cargoTypeId: cargoTypeId || undefined,
cargoTypeCode: cargoTypeCode || undefined,
wagons:
Number.isFinite(parsedWagons) && parsedWagons > 0
? parsedWagons
: undefined,
});
}
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
})
async reviewOperationRequest(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewOperationRequest(
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/doc-requests")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL asks the customer for additional clearance document(s) — shown on the portal with author and time",
})
async requestAdditionalDocuments(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: AuthUserPayload,
) {
await this.transitionService.requestAdditionalDocuments(
id,
note,
resolveAuthUserId(user),
);
return { success: true };
}
@Get(":id/clearance/history")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceEventService.list(id);
}
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them",
})
async getClearanceCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff =
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
return this.clearanceChargeService.listForCustomer(id);
}
@Post(":id/clearance/charges/:chargeId/accept")
@PortalCustomer()
@ApiOperation({
summary:
"Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge",
})
acceptClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerAccept(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/reject")
@PortalCustomer()
@ApiOperation({
summary:
"Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends",
})
rejectClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: RejectClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.customerReject(
id,
chargeId,
dto.note,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/port-document")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Djibouti uploads (or replaces, until billed) the port-charges document",
})
uploadPortChargeDocument(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.uploadPortDocument(
id,
file,
resolveAuthUserId(user),
);
}
@Patch(":id/clearance/charges/:chargeId/bill")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)",
})
billClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.billCharge(
id,
chargeId,
dto,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)",
})
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.sendCharge(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/miscellaneous")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send",
})
createMiscellaneousCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body() dto: BillClearanceChargeDto,
@CurrentUser() user: AuthUserPayload,
) {
if (!file) throw new BadRequestException("A document file is required");
return this.clearanceChargeService.createMiscellaneous(
id,
file,
dto,
resolveAuthUserId(user),
);
}
// ── Additional charges (ad-hoc finance billing) ────────────────────────────
@Get(":id/additional-charges")
@MixedAudience(FREIGHT_PERMS.additionalCharges.view)
@ApiOperation({
summary: "Ad-hoc extra charges finance has raised against this booking",
})
async getAdditionalCharges(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
if (!isStaff) {
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const charges = await this.additionalChargeService.list(id);
// A charge finance hasn't sent yet isn't the customer's to see.
return isStaff ? charges : charges.filter((c) => c.status !== "DRAFT");
}
@Post(":id/additional-charges")
@BookingStaff(FREIGHT_PERMS.additionalCharges.create)
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Finance raises a new additional charge — draft, or send to the customer immediately",
})
createAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: CreateAdditionalChargeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.create(
id,
dto,
resolveAuthUserId(user),
file,
);
}
@Post(":id/additional-charges/:chargeId/send")
@BookingStaff(FREIGHT_PERMS.additionalCharges.send)
@ApiOperation({
summary: "Issue the draft charge's payable invoice and notify the customer",
})
sendAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.send(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/additional-charges/:chargeId/cancel")
@BookingStaff(FREIGHT_PERMS.additionalCharges.cancel)
@ApiOperation({ summary: "Withdraw a draft or unpaid additional charge" })
cancelAdditionalCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@Body() dto: CancelAdditionalChargeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.additionalChargeService.cancel(
id,
chargeId,
resolveAuthUserId(user),
dto.reason,
);
}
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
async uploadClearanceOutput(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/transit-assignee/request")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration",
})
async requestBookingTransitAssignee(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/transit-assignee/assign")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
"GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns",
})
async assignBookingTransitAssignee(
@Param("id", ParseUUIDPipe) id: string,
@Body("transitAgentId", ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "GL ET uploads customs declaration on booking (GENERAL customs)",
})
async uploadBookingDeclaration(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeclaration(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/duty")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
@UseInterceptors(FileInterceptor("attachment"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "GL ET sets duty/tax on booking with notice attachment",
})
async adviseBookingDuty(
@Param("id", ParseUUIDPipe) id: string,
@Body("dutyRequired") dutyRequiredRaw: string,
@Body("amount") amountRaw: string | undefined,
@Body("currency") currency: string | undefined,
@Body("declarationSerial") declarationSerial: string | undefined,
@UploadedFile() attachment: Express.Multer.File | undefined,
@CurrentUser() user: TCurrentUser,
) {
const dutyRequired = dutyRequiredRaw === "true" || dutyRequiredRaw === "1";
const dto: AdviseContractDutyDto = {
dutyRequired,
amount:
amountRaw != null && amountRaw !== "" ? Number(amountRaw) : undefined,
currency: currency ?? "ETB",
declarationSerial,
};
const booking = await this.bookingClearanceService.adviseDuty(
id,
dto,
resolveAuthUserId(user),
attachment,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review",
})
async uploadBookingDraftDeclaration(
@Param("id", ParseUUIDPipe) id: string,
@Body("price") priceRaw: string,
@Body("currency") currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? "ETB",
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration/skip")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
})
async skipBookingDraftDeclaration(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.skipDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration/accept")
@PortalCustomer()
@ApiOperation({
summary:
"Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia",
})
async acceptBookingDraftDeclaration(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration/change")
@PortalCustomer()
@ApiOperation({
summary:
"Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)",
})
async requestBookingDraftDeclarationChange(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking =
await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/finalize-pre-clearance")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" })
async finalizeBookingPreClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/duty-slip")
@PortalCustomer()
@UseInterceptors(FileInterceptor("file"))
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Customer uploads duty/tax payment slip on booking",
})
async uploadBookingDutySlip(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/transit-permit")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingTransitPermit(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadTransitPermit(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/delivery-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingDeliveryOrder(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@Body("vesselArrivalDate") vesselArrivalDate: string | undefined,
@Body("doCollectedDate") doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
files ?? [],
resolveAuthUserId(user),
{ vesselArrivalDate, doCollectedDate },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/release-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingReleaseOrder(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@Body("vesselDepartureDate") vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
files ?? [],
vesselDepartureDate,
resolveAuthUserId(user),
);
return {
...this.transitionService.enrichBookingResponse(result.booking),
hold: result.hold,
holdReason: result.holdReason,
};
}
@Post(":id/clearance/ro-amendment")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
async requestBookingRoAmendment(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.requestRoAmendment(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/export-release")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
async confirmBookingExportRelease(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.confirmExportRelease(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/staff/request-changes")
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/staff/accept")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary:
"Staff accept intake → set contract validity window + start approval chain",
})
async acceptIntake(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
dto.validityDays,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/staff/reject")
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: "Staff final reject" })
async staffReject(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.staffReject(
id,
dto.reason,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite)
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
async governmentExpedite(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: "Generate contract PDF from template" })
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/contract/view")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@Param("id", ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(":id/contract/document")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(":id/contract")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(":id/contract/sign")
@MixedAudience(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
// Staff signature needs the sign permission; customer signs their own booking.
if (dto.role !== "CUSTOMER") {
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
}
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/contract/signatures")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(":id/summary")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(":id/customer/sign")
@PortalCustomer()
@ApiOperation({
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
async customerSign(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/marketing/approve")
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary:
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
})
async marketingApprove(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: "STAFF",
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/operations/start-transit")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: "Mark in transit" })
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/operations/complete")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: "Mark completed" })
async complete(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
// A consolidated pair is held here, not in the operations queue: two
// customers' cargo on one wagon is a commercial call, so a person signs off
// on the pairing before Operations sees either half.
@Get("consolidation-approvals/queue")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiQuery({
name: "status",
required: false,
enum: ConsolidationApprovalStatus,
description: "Filter to one status. Omit for pending first, then decided.",
})
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "pageSize", required: false, type: Number })
@ApiOperation({
summary:
"One page of shared-wagon pairings: pending ones first (oldest first), then the decided history with who decided each. Every row covers BOTH bookings on the wagon.",
})
consolidationApprovalQueue(
@CurrentUser() user: AuthUserPayload,
@Query("status") status?: ConsolidationApprovalStatus,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.consolidationApprovalService.queue({
status,
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
// Narrows to the yards the caller's desk is mapped to.
user,
});
}
@Get(":id/consolidation-approvals")
@BookingStaff(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Approval history for this booking's shared wagon — who decided what, when, and why.",
})
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.consolidationApprovalService.historyForBooking(id);
}
@Post("consolidation-approvals/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
})
approveConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: ApproveConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.approve(
approvalId,
resolveAuthUserId(user) ?? "",
dto.note,
user,
);
}
@Post("consolidation-approvals/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
})
rejectConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: RejectConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.reject(
approvalId,
resolveAuthUserId(user) ?? "",
dto.reason,
user,
);
}
@Post(":id/paired-decision")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({
summary:
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
})
async pairedDecision(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: PairedDecisionDto,
@CurrentUser() user: AuthUserPayload,
) {
const { booking, partner } =
await this.transitionService.applyPairedDecision(
id,
dto.decision,
resolveAuthUserId(user),
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
);
// Sequential enrichment: both go back so the UI can refresh either tab.
const enrichedBooking =
await this.transitionService.enrichBookingResponse(booking);
const enrichedPartner =
await this.transitionService.enrichBookingResponse(partner);
return { booking: enrichedBooking, partner: enrichedPartner };
}
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: "Cancel booking" })
async cancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
"reserved wagons release immediately",
})
async cancelHold(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancelHold(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(":id/consolidation")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}