Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
2026-07-20 12:24:58 +00:00

1185 lines
41 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 {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Request,
Res,
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseGuards,
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 { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } 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,
ApiTags,
} from "@nestjs/swagger";
import type { Response } from "express";
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 { BookingsService } from './bookings.service';
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,
CancelBookingDto,
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 { UpdateBookingDto } from './dto/update-booking.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,
) {}
@Post()
@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")
@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()
@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)) {
return this.bookingsService.findAll(filter);
}
// 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);
}
@Get("by-company/:companyId/customer-view")
@BookingView()
@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")
@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")
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get("by-reference/:reference")
@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")
@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')
@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')
@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')
@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')
@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')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
async customerTruckFreightOrder(
@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.customerTruckFreightOrderCopies(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/customer-trucks')
@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')
@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);
}
@Patch(':id/customer-trucks/:assignmentId')
@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')
@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')
@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);
}
@Post(':id/customer-trucks/:assignmentId/load')
@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')
@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')
@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')
@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')
@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")
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(":id/documents")
@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")
@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")
@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")
@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")
@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() {
return this.bookingClearanceService.etQueue();
}
@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')
@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")
@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[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/proceed")
@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,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
);
return this.transitionService.enrichBookingResponse(booking);
}
@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/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[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
);
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) {
const booking = await this.transitionService.finalizeClearance(id);
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/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) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/duty-slip')
@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,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
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(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async uploadBookingReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
file,
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.staffAccept)
@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")
@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")
@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")
@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")
@UseGuards(JwtGuard)
@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")
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(":id/summary")
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(":id/customer/sign")
@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);
}
@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/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(":id/consolidation")
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(":id/consolidation")
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}