mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
1935 lines
68 KiB
TypeScript
1935 lines
68 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,
|
||
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 {
|
||
BillClearanceChargeDto,
|
||
RejectClearanceChargeDto,
|
||
} from './dto/clearance-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 { 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,
|
||
) {}
|
||
|
||
@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 (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(@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),
|
||
);
|
||
}
|
||
|
||
@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/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)
|
||
@ApiOperation({
|
||
summary:
|
||
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
|
||
})
|
||
consolidationApprovalQueue() {
|
||
return this.consolidationApprovalService.queue();
|
||
}
|
||
|
||
@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,
|
||
);
|
||
}
|
||
|
||
@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,
|
||
);
|
||
}
|
||
|
||
@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);
|
||
}
|
||
}
|