Files
edr-platform/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts
Hagernesh 8181834037 Ticket
Empty container returns now generate a downloadable Equipment Interchange Receipt: container number & size, exact return timestamp, depot/location, condition, and the carrier/booking reference. Customers can download it from their booking's Documents tab (own-booking returns only); staff can pull any via the same endpoint.
2026-08-21 08:59:06 +00:00

196 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from '../bookings/bookings.service';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
LoadEmptyContainersOnTrainDto,
RecordDeclarationDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import { ImportOperationsService } from './import-operations.service';
@ApiTags('import-operations')
@ApiBearerAuth()
@Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones). No
// class-level guard: the equipment interchange receipt below is customer-reachable,
// every other route here stays staff-only via its own @BookingStaff.
export class ImportOperationsController {
constructor(
private readonly service: ImportOperationsService,
private readonly bookingsService: BookingsService,
) {}
@Get('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId);
}
@Post('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto);
}
@Get('customs/:bookingId')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId);
}
@Post('customs/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: UploadImportCustomsDocumentDto,
) {
return this.service.uploadCustomsDocument(bookingId, dto);
}
@Post('customs/:bookingId/declaration')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: RecordDeclarationDto,
) {
return this.service.recordDeclaration(bookingId, dto);
}
@Post('customs/:bookingId/notify-duties-taxes')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.notifyDutiesTaxes(bookingId, dto);
}
@Post('customs/:bookingId/duties-taxes-paid')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markDutiesTaxesPaid(bookingId, dto);
}
@Post('customs/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto);
}
@Post('customs/:bookingId/release-permitted')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markReleasePermitted(bookingId, dto);
}
@Get('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() {
return this.service.listEmptyReturns();
}
@Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
})
loadEmptyReturnsOnTrain(@Body() dto: LoadEmptyContainersOnTrainDto) {
return this.service.loadEmptyReturnsOnTrain(dto);
}
@Post('empty-container-returns/:id/status')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEmptyContainerReturnStatusDto,
) {
return this.service.updateEmptyReturnStatus(id, dto);
}
@Get('bookings/:bookingId/empty-container-returns')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' })
async listEmptyReturnsForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertCanAccessBooking(user, bookingId);
return this.service.listEmptyReturnsForBooking(bookingId);
}
@Get('empty-container-returns/:id/document')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' })
async equipmentInterchangeDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const row = await this.service.getEmptyReturnOrThrow(id);
// A standalone (no-booking) return has no owner to check against, so it
// stays staff-only.
if (!row.bookingId) {
await this.assertCanAccessBooking(user, null);
} else {
await this.assertCanAccessBooking(user, row.bookingId);
}
const { filename, buffer } = await this.service.equipmentInterchangeDocument(row);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
/**
* Staff pass on permission alone. A customer must own the booking; `null`
* (a standalone, booking-less return) has no owner for a customer to match,
* so it 404s them the same way a foreign booking would.
*/
private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise<void> {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return;
if (!bookingId) {
throw new NotFoundException('Not found');
}
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
}