mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
Param,
|
|
ParseUUIDPipe,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
Request,
|
|
Res,
|
|
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 {
|
|
ApproveStepDto,
|
|
CancelBookingDto,
|
|
RejectStepDto,
|
|
RequestChangesDto,
|
|
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)' })
|
|
findAll(@Query() filter: FilterBookingDto) {
|
|
return this.bookingsService.findAll(filter);
|
|
}
|
|
|
|
@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('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) {
|
|
const booking = await this.bookingsService.findByReference(reference);
|
|
return this.transitionService.enrichBookingResponse(booking);
|
|
}
|
|
|
|
@Get(':id')
|
|
@ApiOperation({ summary: 'Get booking by ID' })
|
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
|
const booking = await this.bookingsService.findById(id);
|
|
return this.transitionService.enrichBookingResponse(booking);
|
|
}
|
|
|
|
@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/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 → start approval chain' })
|
|
async acceptIntake(
|
|
@Param('id', ParseUUIDPipe) id: string,
|
|
@CurrentUser() user: AuthUserPayload,
|
|
) {
|
|
const booking = await this.transitionService.acceptIntake(
|
|
id,
|
|
resolveAuthUserId(user),
|
|
);
|
|
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) {
|
|
return this.contractService.getContractView(id);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|