mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Export cargo reaches a train two ways, but the platform only modelled one. Direct truck-to-train cargo loads straight onto the wagon, never enters a warehouse and so never has a GRN — yet assertExportReceivedWithGrn required one before the carriage acceptance sheet could be issued or the booking loaded from inside its schedule. Adds export_handover_mode to freight.bookings (null = WAREHOUSE, so existing bookings are unaffected) and teaches the shared gate to skip DIRECT_TO_TRAIN. Both call sites are fixed by that single early return. For direct bookings the carriage acceptance sheet builds its lines from the booking's own containers, falling back to the declared bulk tonnage, and is issuable as soon as the mode is chosen. Direct bookings are also removed from the warehouse receive queue, since that cargo is never coming to the shed. Staff choose the mode from the booking detail page via a new endpoint reusing bookings:operations. Switching to direct is refused once warehouse inventory exists, so the two flows cannot cross. Warehouse-then-train keeps every gate it had.
1604 lines
57 KiB
TypeScript
1604 lines
57 KiB
TypeScript
import {
|
||
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,
|
||
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 { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.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 { 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,
|
||
) {}
|
||
|
||
@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")
|
||
@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 (owner, or staff with the void permission)' })
|
||
async withdrawWagonCancellation(
|
||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
await this.assertWagonCancellationActor(
|
||
cancellationId,
|
||
user,
|
||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||
);
|
||
return this.wagonCancellationService.withdraw(cancellationId);
|
||
}
|
||
|
||
@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() {
|
||
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')
|
||
@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[],
|
||
) {
|
||
const booking = await this.transitionService.submitClearanceDocuments(
|
||
id,
|
||
files ?? [],
|
||
);
|
||
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,
|
||
) {
|
||
const booking = await this.transitionService.requestOperation(
|
||
id,
|
||
dto.scheduledDate,
|
||
dto.trainScheduleId ?? null,
|
||
);
|
||
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/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/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,
|
||
) {
|
||
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
|
||
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,
|
||
) {
|
||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
|
||
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/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) {
|
||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
||
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) {
|
||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||
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,
|
||
) {
|
||
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('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||
id,
|
||
file,
|
||
resolveAuthUserId(user),
|
||
{ vesselArrivalDate, doCollectedDate },
|
||
);
|
||
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.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);
|
||
}
|
||
|
||
@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);
|
||
}
|
||
}
|