mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Create migration for booking_requests table with necessary fields and indexes. - Implement BookingRequestRepository for database operations related to booking requests. - Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests. - Create DTOs for creating booking requests and reviewing them. - Define BookingRequest entity to map to the booking_requests table. - Add UI components for managing shipment requests, including detail and list pages. - Implement OperationDatePicker component for selecting available shipment days.
714 lines
24 KiB
TypeScript
714 lines
24 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
Param,
|
|
ParseUUIDPipe,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
Request,
|
|
Res,
|
|
UnauthorizedException,
|
|
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 } from '../../common/booking-guards';
|
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|
import { AnyFilesInterceptor } 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 { 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,
|
|
ApproveStepDto,
|
|
CancelBookingDto,
|
|
RejectBookingDto,
|
|
RejectStepDto,
|
|
RequestChangesDto,
|
|
ReviewDocumentDto,
|
|
RequestOperationDto,
|
|
OperationReviewDto,
|
|
StaffRejectDto,
|
|
} from './dto/request-changes.dto';
|
|
import { ContractViewDto } from './dto/contract-view.dto';
|
|
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';
|
|
|
|
@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,
|
|
) {}
|
|
|
|
@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')
|
|
@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')
|
|
@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')
|
|
@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/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(':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/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/approval-steps/:stepId/approve')
|
|
@BookingStaff([
|
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
|
FREIGHT_PERMS.bookings.approveDirector,
|
|
FREIGHT_PERMS.bookings.approveCeo,
|
|
])
|
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
|
async approveStep(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
|
@Body() dto: ApproveStepDto,
|
|
@CurrentUser() user: TCurrentUser,
|
|
) {
|
|
const booking = await this.transitionService.approveStep(
|
|
id,
|
|
stepId,
|
|
resolveAuthUserId(user),
|
|
dto.requiredRole,
|
|
user,
|
|
);
|
|
return this.transitionService.enrichBookingResponse(booking);
|
|
}
|
|
|
|
@Post(':id/approval-steps/:stepId/reject')
|
|
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
|
@ApiOperation({ summary: 'Reject at approval step' })
|
|
async rejectStep(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
|
@Body() dto: RejectStepDto,
|
|
@CurrentUser() user: AuthUserPayload,
|
|
) {
|
|
const booking = await this.transitionService.rejectStep(
|
|
id,
|
|
stepId,
|
|
resolveAuthUserId(user),
|
|
dto.reason,
|
|
);
|
|
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')
|
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
|
async signContract(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@Body() dto: SignContractDto,
|
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
|
) {
|
|
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);
|
|
}
|
|
}
|