mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1185 lines
41 KiB
TypeScript
1185 lines
41 KiB
TypeScript
import {
|
||
Body,
|
||
Controller,
|
||
Delete,
|
||
ForbiddenException,
|
||
Get,
|
||
HttpCode,
|
||
Param,
|
||
ParseUUIDPipe,
|
||
Patch,
|
||
Post,
|
||
Query,
|
||
Request,
|
||
Res,
|
||
UnauthorizedException,
|
||
UploadedFile,
|
||
UploadedFiles,
|
||
UseGuards,
|
||
UseInterceptors,
|
||
} from '@nestjs/common';
|
||
import { CurrentUser } from '@edr/api-common';
|
||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||
import { BookingStaff, BookingView } from '../../common/booking-guards';
|
||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||
import {
|
||
ApiBearerAuth,
|
||
ApiBody,
|
||
ApiConsumes,
|
||
ApiOkResponse,
|
||
ApiOperation,
|
||
ApiTags,
|
||
} from "@nestjs/swagger";
|
||
import type { Response } from "express";
|
||
|
||
import { BookingContractService } from './booking-contract.service';
|
||
import { BookingPricingService } from './booking-pricing.service';
|
||
import { BookingTransitionService } from './booking-transition.service';
|
||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||
import {
|
||
AdviseContractDutyDto,
|
||
RoAmendmentDto,
|
||
} from '../contracts/dto/phased-clearance.dto';
|
||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||
import { BookingsService } from './bookings.service';
|
||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||
import {
|
||
AcceptIntakeDto,
|
||
CancelBookingDto,
|
||
RejectBookingDto,
|
||
RequestChangesDto,
|
||
ReviewDocumentDto,
|
||
RequestOperationDto,
|
||
OperationReviewDto,
|
||
StaffRejectDto,
|
||
} from './dto/request-changes.dto';
|
||
import { ContractViewDto } from './dto/contract-view.dto';
|
||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||
import { CustomerTruckService } from './customer-truck.service';
|
||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||
import { LastMileService } from '../last-mile/last-mile.service';
|
||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||
import { ContainerReceiptService } from './container-receipt.service';
|
||
import { SignContractDto } from './dto/sign-contract.dto';
|
||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||
import {
|
||
type AuthUserPayload,
|
||
resolveAuthUserId,
|
||
} from "../../common/resolve-auth-user-id";
|
||
import {
|
||
assertFreightPermission,
|
||
hasFreightPermission,
|
||
} from "../../common/freight-permission.util";
|
||
|
||
interface MileVehicleSummary {
|
||
plate: string | null;
|
||
code: string | null;
|
||
driverName: string | null;
|
||
containerNumber: string | null;
|
||
distanceKm: number | null;
|
||
}
|
||
|
||
interface MileLegSummary {
|
||
status: string;
|
||
exactKm: number | null;
|
||
remainingPayment: number | null;
|
||
currency: string;
|
||
invoiced: boolean;
|
||
vehicles: MileVehicleSummary[];
|
||
}
|
||
|
||
/** Trim a first/last-mile record down to a customer-safe operational summary. */
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
|
||
if (!rec) return null;
|
||
const num = (v: unknown) => (v == null ? null : Number(v));
|
||
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||
const currency =
|
||
rec.vehicle?.currency ??
|
||
assignments[0]?.vehicle?.currency ??
|
||
rec.booking?.paymentCurrency ??
|
||
'ETB';
|
||
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
|
||
plate: a.vehicle?.plateNumber ?? null,
|
||
code: a.vehicle?.code ?? null,
|
||
driverName: a.vehicle?.assignedDriverName ?? null,
|
||
containerNumber: a.containerNumber ?? null,
|
||
distanceKm: num(a.distanceKm),
|
||
}));
|
||
if (!vehicles.length && rec.vehicle) {
|
||
vehicles.push({
|
||
plate: rec.vehicle.plateNumber ?? null,
|
||
code: rec.vehicle.code ?? null,
|
||
driverName: rec.vehicle.assignedDriverName ?? null,
|
||
containerNumber: null,
|
||
distanceKm: num(rec.exactKm),
|
||
});
|
||
}
|
||
return {
|
||
status: rec.status ?? '',
|
||
exactKm: num(rec.exactKm),
|
||
remainingPayment: num(rec.remainingPayment),
|
||
currency,
|
||
invoiced: Boolean(rec.invoice),
|
||
vehicles,
|
||
};
|
||
}
|
||
|
||
@ApiTags("bookings")
|
||
@Controller("bookings")
|
||
@ApiBearerAuth()
|
||
export class BookingsController {
|
||
constructor(
|
||
private readonly bookingsService: BookingsService,
|
||
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||
private readonly pricingService: BookingPricingService,
|
||
private readonly transitionService: BookingTransitionService,
|
||
private readonly contractService: BookingContractService,
|
||
private readonly bookingClearanceService: BookingClearanceService,
|
||
private readonly customerTruckService: CustomerTruckService,
|
||
private readonly containerReceiptService: ContainerReceiptService,
|
||
private readonly firstMileService: FirstMileService,
|
||
private readonly lastMileService: LastMileService,
|
||
) {}
|
||
|
||
@Post()
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes("multipart/form-data")
|
||
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||
@ApiBody({ type: CreateBookingDto })
|
||
async create(
|
||
@Body() dto: CreateBookingDto,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
if (dto.isGovernment) {
|
||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||
}
|
||
const result = await this.bookingsService.create(
|
||
dto,
|
||
files ?? [],
|
||
user?.id,
|
||
);
|
||
|
||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||
const isStaff = hasFreightPermission(
|
||
user,
|
||
FREIGHT_PERMS.bookings.staffAccept,
|
||
);
|
||
if (isStaff && !dto.isGovernment) {
|
||
try {
|
||
await this.pricingService.generatePrice(result.booking.id);
|
||
await this.transitionService.submit(result.booking.id);
|
||
const submitted = await this.bookingsService.findById(
|
||
result.booking.id,
|
||
);
|
||
return { booking: submitted, warnings: result.warnings };
|
||
} catch {
|
||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||
return result;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
@Patch(":id")
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes("multipart/form-data")
|
||
@ApiOperation({
|
||
summary: "Update booking",
|
||
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
|
||
})
|
||
@ApiBody({ type: UpdateBookingDto })
|
||
update(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: UpdateBookingDto,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
) {
|
||
return this.bookingsService.update(id, dto, files ?? []);
|
||
}
|
||
|
||
@Get()
|
||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||
async findAll(
|
||
@Query() filter: FilterBookingDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
// Staff (backoffice) see every booking. Customers (portal) are always
|
||
// force-scoped to their own company, regardless of any companyId they pass.
|
||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
return this.bookingsService.findAll(filter);
|
||
}
|
||
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
|
||
// to the customs document-clearance queue only and never sees the general
|
||
// booking-request list.
|
||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
|
||
return this.bookingsService.findClearanceQueue(filter);
|
||
}
|
||
const userId = user?.id;
|
||
if (!userId) throw new UnauthorizedException("Authentication required");
|
||
const companyId =
|
||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||
if (!companyId) {
|
||
const page = filter.page ?? 1;
|
||
const pageSize = filter.pageSize ?? 20;
|
||
return {
|
||
items: [],
|
||
total: 0,
|
||
meta: {
|
||
page,
|
||
pageSize,
|
||
total: 0,
|
||
totalPages: 0,
|
||
hasNextPage: false,
|
||
hasPreviousPage: false,
|
||
},
|
||
};
|
||
}
|
||
// Company-wide by default; the optional filter.companyProfileId (per-page
|
||
// service filter) narrows within the company. The company guard always
|
||
// applies, so a customer can only ever see their own company's bookings.
|
||
return this.bookingsService.findAll(filter, companyId);
|
||
}
|
||
|
||
@Get("by-company/:companyId/customer-view")
|
||
@BookingView()
|
||
@ApiOperation({
|
||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||
})
|
||
findByCompanyCustomerView(
|
||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||
) {
|
||
return this.bookingsService.findCustomerBookings(companyId);
|
||
}
|
||
|
||
@Get("list-summary")
|
||
@BookingView()
|
||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||
findListSummary(@Query() filter: FilterBookingDto) {
|
||
return this.bookingsService.getListSummary(filter);
|
||
}
|
||
|
||
@Get("my")
|
||
@ApiOperation({
|
||
summary: "List the current customer's bookings ready for payment",
|
||
description:
|
||
"Bookings owned by the authenticated user's company that are payable " +
|
||
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
|
||
})
|
||
findMyPayable(
|
||
@CurrentUser() user: AuthUserPayload,
|
||
@Query() filter: FilterBookingDto,
|
||
) {
|
||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||
}
|
||
|
||
@Get("queues/:queue")
|
||
@BookingView()
|
||
@ApiOperation({
|
||
summary: "List bookings for a dashboard queue",
|
||
description: "Queues: intake, approval, signatures, marketing, finance",
|
||
})
|
||
findQueue(
|
||
@Param("queue") queue: string,
|
||
@Query() filter: FilterBookingDto,
|
||
@Query("excludeBulk") excludeBulk?: string,
|
||
) {
|
||
return this.bookingsService.findQueue(queue, filter, {
|
||
excludeBulk: excludeBulk === "true",
|
||
});
|
||
}
|
||
|
||
@Get("reference-data")
|
||
@ApiOperation({ summary: "Booking form catalog" })
|
||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||
return this.bookingReferenceDataService.getReferenceData();
|
||
}
|
||
|
||
@Get("by-reference/:reference")
|
||
@ApiOperation({ summary: "Get booking by reference" })
|
||
async findByReference(
|
||
@Param("reference") reference: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findByReference(reference);
|
||
// Staff see any booking; customers only their own company's.
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||
user?.id,
|
||
booking,
|
||
);
|
||
}
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Get(":id")
|
||
@ApiOperation({ summary: "Get booking by ID" })
|
||
async findOne(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
// Staff see any booking; Global Logistics (clearance:view) may inspect any
|
||
// booking for the clearance gate; customers only their own company's.
|
||
if (
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||
) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||
user?.id,
|
||
booking,
|
||
);
|
||
}
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Get(':id/available-days')
|
||
@ApiOperation({
|
||
summary:
|
||
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||
})
|
||
async availableDays(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||
) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||
user?.id,
|
||
booking,
|
||
);
|
||
}
|
||
return this.bookingsService.availableDaysForBooking(id);
|
||
}
|
||
|
||
@Get(':id/day-availability')
|
||
@ApiOperation({
|
||
summary:
|
||
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
|
||
'Export: whole-booking fit + largest single-train leftover. ' +
|
||
'Import/domestic: total room across the day for the booking\'s wagon type.',
|
||
})
|
||
async dayAvailability(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Query('date') date: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||
) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||
user?.id,
|
||
booking,
|
||
);
|
||
}
|
||
return this.transitionService.dayAvailabilityForBooking(id, date);
|
||
}
|
||
|
||
@Get(':id/mile-summary')
|
||
@ApiOperation({
|
||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||
})
|
||
async mileSummary(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
// Customers may only see their own booking's mile summary.
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||
) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
|
||
const [first, last] = await Promise.all([
|
||
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
|
||
]);
|
||
return {
|
||
firstMile: summarizeMileLeg(first.data[0]),
|
||
lastMile: summarizeMileLeg(last.data[0]),
|
||
};
|
||
}
|
||
|
||
@Post(':id/customer-truck-assignment')
|
||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||
async assignCustomerTruck(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: CustomerTruckAssignmentDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
|
||
return this.transitionService.enrichBookingResponse(assigned);
|
||
}
|
||
|
||
@Get(':id/customer-truck-assignment/freight-order')
|
||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||
async customerTruckFreightOrder(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
@Res() res: Response,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
const { filename, buffer } =
|
||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||
res.setHeader('Content-Type', 'application/pdf');
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||
res.send(buffer);
|
||
}
|
||
|
||
@Get(':id/customer-trucks')
|
||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||
async listCustomerTrucks(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
return this.customerTruckService.listTrucks(id);
|
||
}
|
||
|
||
@Post(':id/customer-trucks')
|
||
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 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);
|
||
}
|
||
|
||
@Patch(':id/customer-trucks/:assignmentId')
|
||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||
async updateCustomerTruck(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||
@Body() dto: AddCustomerTruckDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
return this.customerTruckService.updateTruck(id, assignmentId, dto);
|
||
}
|
||
|
||
@Delete(':id/customer-trucks/:assignmentId')
|
||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||
async removeCustomerTruck(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
return this.customerTruckService.removeTruck(id, assignmentId);
|
||
}
|
||
|
||
@Get(':id/customer-trucks/loadable-containers')
|
||
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
|
||
async loadableContainers(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||
}
|
||
return this.customerTruckService.getLoadableContainers(id);
|
||
}
|
||
|
||
@Post(':id/customer-trucks/:assignmentId/load')
|
||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||
async loadCustomerTruck(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||
@Body() dto: LoadCustomerTruckDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
throw new ForbiddenException('Only warehouse staff can load a truck');
|
||
}
|
||
return this.customerTruckService.loadTruck(id, assignmentId, dto);
|
||
}
|
||
|
||
@Post(':id/customer-trucks/:assignmentId/depart')
|
||
@ApiOperation({
|
||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||
})
|
||
async departCustomerTruck(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||
@Body() dto: DepartCustomerTruckDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
// Weighing + registering the load on exit is a warehouse/gate staff action.
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
throw new ForbiddenException('Only warehouse staff can register a truck departure');
|
||
}
|
||
return this.customerTruckService.departTruck(id, assignmentId, dto);
|
||
}
|
||
|
||
@Get(':id/received-pending-grn')
|
||
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||
async receivedPendingGrn(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
// GRN is a warehouse-staff action — no customer access.
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||
}
|
||
return this.containerReceiptService.listReceivedPendingGrn(id);
|
||
}
|
||
|
||
@Post(':id/generate-grn')
|
||
@ApiOperation({
|
||
summary:
|
||
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||
})
|
||
async generateGrn(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: GenerateGrnDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
// GRN is a warehouse-staff action — no customer access.
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||
}
|
||
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
|
||
}
|
||
|
||
@Get(':id/tracking')
|
||
@ApiOperation({
|
||
summary: "Shipment tracking timeline for a booking",
|
||
description:
|
||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||
"tracking events. Scoped to the customer's own company.",
|
||
})
|
||
async findTracking(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingsService.findById(id);
|
||
// Staff see any booking; customers only their own company's.
|
||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||
user?.id,
|
||
booking,
|
||
);
|
||
}
|
||
return this.bookingsService.getBookingTracking(id);
|
||
}
|
||
|
||
@Delete(":id")
|
||
@HttpCode(204)
|
||
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.bookingsService.remove(id);
|
||
}
|
||
|
||
@Post(":id/documents")
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes("multipart/form-data")
|
||
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||
async uploadDocuments(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
) {
|
||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/generate-price")
|
||
@ApiOperation({
|
||
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||
description:
|
||
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
|
||
})
|
||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.pricingService.generatePrice(id);
|
||
}
|
||
|
||
@Post(":id/submit")
|
||
@ApiOperation({
|
||
summary: "Customer submit booking",
|
||
description:
|
||
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
|
||
})
|
||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||
submit(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.transitionService.submit(id);
|
||
}
|
||
|
||
@Post(":id/confirm-submit")
|
||
@ApiOperation({
|
||
summary: "Confirm submit after price change",
|
||
description:
|
||
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
|
||
})
|
||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.transitionService.confirmSubmit(id);
|
||
}
|
||
|
||
@Post(":id/reject")
|
||
@ApiOperation({
|
||
summary: "Customer reject price estimate",
|
||
description:
|
||
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
|
||
})
|
||
async reject(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: RejectBookingDto,
|
||
) {
|
||
const booking = await this.transitionService.reject(id, dto.reason);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||
|
||
@Get('clearance/et-queue')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||
getBookingEtClearanceQueue() {
|
||
return this.bookingClearanceService.etQueue();
|
||
}
|
||
|
||
@Get('clearance/dj-queue')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||
getBookingDjClearanceQueue() {
|
||
return this.bookingClearanceService.djQueue();
|
||
}
|
||
|
||
@Get(':id/clearance')
|
||
@ApiOperation({
|
||
summary:
|
||
"Document-clearance grid (required docs + upload + GL review status)",
|
||
})
|
||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.transitionService.getClearanceView(id);
|
||
}
|
||
|
||
@Post(":id/clearance/documents")
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes("multipart/form-data")
|
||
@ApiOperation({
|
||
summary: "Customer uploads clearance documents (fieldname = document key)",
|
||
})
|
||
async submitClearanceDocuments(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
) {
|
||
const booking = await this.transitionService.submitClearanceDocuments(
|
||
id,
|
||
files ?? [],
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/clearance/proceed")
|
||
@ApiOperation({
|
||
summary:
|
||
"Customer requests operation with a schedule day " +
|
||
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||
})
|
||
async proceedToOperation(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: RequestOperationDto,
|
||
) {
|
||
const booking = await this.transitionService.requestOperation(
|
||
id,
|
||
dto.scheduledDate,
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/operation/review")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||
@ApiOperation({
|
||
summary:
|
||
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
|
||
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
|
||
})
|
||
async reviewOperationRequest(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: OperationReviewDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.transitionService.reviewOperationRequest(
|
||
id,
|
||
dto.decision,
|
||
resolveAuthUserId(user),
|
||
{ note: dto.note },
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/clearance/review")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||
@ApiOperation({
|
||
summary: "GL reviews a clearance document (Approve | Query)",
|
||
})
|
||
async reviewClearanceDocument(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: ReviewDocumentDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.transitionService.reviewDocument(
|
||
id,
|
||
dto.fileKey,
|
||
dto.status,
|
||
resolveAuthUserId(user),
|
||
dto.note,
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/clearance/output-documents")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes("multipart/form-data")
|
||
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
|
||
async uploadClearanceOutput(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
) {
|
||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||
id,
|
||
files ?? [],
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/clearance/finalize")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||
@ApiOperation({
|
||
summary:
|
||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||
})
|
||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||
const booking = await this.transitionService.finalizeClearance(id);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/declaration')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes('multipart/form-data')
|
||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||
async uploadBookingDeclaration(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||
id,
|
||
files ?? [],
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/duty')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||
@UseInterceptors(FileInterceptor('attachment'))
|
||
@ApiConsumes('multipart/form-data')
|
||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||
async adviseBookingDuty(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||
@Body('amount') amountRaw: string | undefined,
|
||
@Body('currency') currency: string | undefined,
|
||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||
const dto: AdviseContractDutyDto = {
|
||
dutyRequired,
|
||
amount:
|
||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||
currency: currency ?? 'ETB',
|
||
declarationSerial,
|
||
};
|
||
const booking = await this.bookingClearanceService.adviseDuty(
|
||
id,
|
||
dto,
|
||
resolveAuthUserId(user),
|
||
attachment,
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/finalize-pre-clearance')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/duty-slip')
|
||
@UseInterceptors(FileInterceptor('file'))
|
||
@ApiConsumes('multipart/form-data')
|
||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||
async uploadBookingDutySlip(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFile() file: Express.Multer.File,
|
||
) {
|
||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/transit-permit')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||
@UseInterceptors(AnyFilesInterceptor())
|
||
@ApiConsumes('multipart/form-data')
|
||
async uploadBookingTransitPermit(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||
id,
|
||
files ?? [],
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/delivery-order')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||
@UseInterceptors(FileInterceptor('file'))
|
||
@ApiConsumes('multipart/form-data')
|
||
async uploadBookingDeliveryOrder(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFile() file: Express.Multer.File,
|
||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||
id,
|
||
file,
|
||
resolveAuthUserId(user),
|
||
vesselDepartureDate,
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/release-order')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||
@UseInterceptors(FileInterceptor('file'))
|
||
@ApiConsumes('multipart/form-data')
|
||
async uploadBookingReleaseOrder(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFile() file: Express.Multer.File,
|
||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||
id,
|
||
file,
|
||
vesselDepartureDate,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return {
|
||
...this.transitionService.enrichBookingResponse(result.booking),
|
||
hold: result.hold,
|
||
holdReason: result.holdReason,
|
||
};
|
||
}
|
||
|
||
@Post(':id/clearance/ro-amendment')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||
async requestBookingRoAmendment(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: RoAmendmentDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||
id,
|
||
dto.note,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/clearance/export-release')
|
||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||
async confirmBookingExportRelease(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||
id,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(':id/staff/request-changes')
|
||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||
async requestChanges(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: RequestChangesDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.transitionService.requestChanges(
|
||
id,
|
||
dto.note,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/staff/accept")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||
@ApiOperation({
|
||
summary:
|
||
"Staff accept intake → set contract validity window + start approval chain",
|
||
})
|
||
async acceptIntake(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: AcceptIntakeDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.transitionService.acceptIntake(
|
||
id,
|
||
resolveAuthUserId(user),
|
||
dto.validityDays,
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/staff/reject")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||
@ApiOperation({ summary: "Staff final reject" })
|
||
async staffReject(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: StaffRejectDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.transitionService.staffReject(
|
||
id,
|
||
dto.reason,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/government-expedite")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||
@ApiOperation({
|
||
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||
})
|
||
async governmentExpedite(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
const booking = await this.bookingsService.governmentExpedite(
|
||
id,
|
||
resolveAuthUserId(user),
|
||
);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/contract/generate")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
|
||
const booking = await this.contractService.generateContract(id);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Get(":id/contract/view")
|
||
@ApiOkResponse({ type: ContractViewDto })
|
||
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||
getContractView(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Request() req: { user?: { id?: string; sub?: string } },
|
||
) {
|
||
const userId = req.user?.id ?? req.user?.sub;
|
||
return this.contractService.getContractView(id, userId);
|
||
}
|
||
|
||
@Get(":id/contract/document")
|
||
@ApiOperation({ summary: "Download contract PDF" })
|
||
async downloadContractDocument(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Res() res: Response,
|
||
): Promise<void> {
|
||
const { stream, record } = await this.contractService.streamContract(id);
|
||
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
|
||
res.setHeader(
|
||
"Content-Disposition",
|
||
`attachment; filename="${record.name}"`,
|
||
);
|
||
stream.pipe(res);
|
||
}
|
||
|
||
@Get(":id/contract")
|
||
@ApiOperation({ summary: "Download contract file (alias)" })
|
||
async downloadContract(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Res() res: Response,
|
||
): Promise<void> {
|
||
return this.downloadContractDocument(id, res);
|
||
}
|
||
|
||
@Post(":id/contract/sign")
|
||
@UseGuards(JwtGuard)
|
||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||
async signContract(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: SignContractDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||
) {
|
||
// Staff signature needs the sign permission; customer signs their own booking.
|
||
if (dto.role !== "CUSTOMER") {
|
||
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
|
||
}
|
||
const userId = req.user?.id ?? req.user?.sub;
|
||
const booking = await this.contractService.signContract(id, dto, {
|
||
signerUserId: userId,
|
||
ipAddress: req.ip,
|
||
});
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Get(":id/contract/signatures")
|
||
@ApiOperation({ summary: "List contract signatures" })
|
||
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.contractService.getSignatures(id);
|
||
}
|
||
|
||
@Get(":id/summary")
|
||
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.contractService.getSummary(id);
|
||
}
|
||
|
||
@Post(":id/customer/sign")
|
||
@ApiOperation({
|
||
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||
})
|
||
async customerSign(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: SignContractDto,
|
||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||
) {
|
||
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
|
||
const booking = await this.contractService.signContract(id, payload, {
|
||
signerUserId: req.user?.id ?? req.user?.sub,
|
||
ipAddress: req.ip,
|
||
});
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/marketing/approve")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||
@ApiOperation({
|
||
summary:
|
||
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
|
||
})
|
||
async marketingApprove(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: SignContractDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
@Request() req: { ip?: string },
|
||
) {
|
||
const payload: SignContractDto = {
|
||
...dto,
|
||
role: "STAFF",
|
||
};
|
||
const booking = await this.contractService.signContract(id, payload, {
|
||
signerUserId: resolveAuthUserId(user),
|
||
ipAddress: req.ip,
|
||
});
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/operations/start-transit")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||
@ApiOperation({ summary: "Mark in transit" })
|
||
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
|
||
const booking = await this.transitionService.startTransit(id);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/operations/complete")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||
@ApiOperation({ summary: "Mark completed" })
|
||
async complete(@Param("id", ParseUUIDPipe) id: string) {
|
||
const booking = await this.transitionService.complete(id);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/cancel")
|
||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||
@ApiOperation({ summary: "Cancel booking" })
|
||
async cancel(
|
||
@Param("id", ParseUUIDPipe) id: string,
|
||
@Body() dto: CancelBookingDto,
|
||
) {
|
||
const booking = await this.transitionService.cancel(id, dto.reason);
|
||
return this.transitionService.enrichBookingResponse(booking);
|
||
}
|
||
|
||
@Post(":id/consolidation")
|
||
@ApiOperation({ summary: "Request freight consolidation" })
|
||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.bookingsService.requestConsolidation(id);
|
||
}
|
||
|
||
@Delete(":id/consolidation")
|
||
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.bookingsService.removeConsolidation(id);
|
||
}
|
||
|
||
@Get(":id/consolidation")
|
||
@ApiOperation({ summary: "Get consolidation details" })
|
||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||
return this.bookingsService.getConsolidationDetails(id);
|
||
}
|
||
}
|