mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
- 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.
2209 lines
74 KiB
TypeScript
2209 lines
74 KiB
TypeScript
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 1–2 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);
|
||
}
|
||
}
|