mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
feat: add Transit Clearance Action Panel for managing delivery and release orders
- Implemented TransitClearanceActionPanel component for transit agents to handle DO/RO uploads and amendments. - Added file picker for uploading documents with validation for vessel arrival and collection dates. - Introduced modals for uploading T1 documents and requesting RO amendments. - Enhanced transit assignments service with new API endpoints for clearance history, GL exchange documents, and incident reports. - Updated types to include new document upload sources and statuses. - Created CSS styles for transit bookings table to improve layout and responsiveness. - Exported new TransitAgentBookingDetailPage for detailed booking views.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -783,6 +784,64 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
/**
|
||||
* Scope a clearance READ that a transit agent may be making.
|
||||
*
|
||||
* Transit agents are portal accounts holding no permission and belonging to
|
||||
* no company, so the audience guards admit them but the usual company-based
|
||||
* ownership check would 404 every booking. This narrows them to the shipments
|
||||
* assigned to them and leaves every other caller — staff and owning customers
|
||||
* — on the path they already had. Purely widening: nothing that passed before
|
||||
* starts failing here.
|
||||
*/
|
||||
private async assertTransitAgentScope(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
const userId = user?.id;
|
||||
if (!userId) return;
|
||||
if (!(await this.bookingsService.isTransitAgent(userId))) return;
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
|
||||
) {
|
||||
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
|
||||
// customer-ownership failure mode.
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a formerly staff-only clearance route that is now MixedAudience.
|
||||
*
|
||||
* Staff still pass on their permission. A portal caller must be a transit
|
||||
* agent assigned to THIS booking — an ordinary customer is rejected, because
|
||||
* relaxing the guard must not hand the whole customer base a route that was
|
||||
* previously staff-only.
|
||||
*
|
||||
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
|
||||
* assigned agent files them in the desk's place, and the assignment is the
|
||||
* only thing standing between a portal token and the customs record.
|
||||
*/
|
||||
private async assertPortalClearanceAccess(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
@@ -1128,6 +1187,9 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/clearance")
|
||||
// A transit agent is a portal account, so MixedAudience admits them without a
|
||||
// permission; `assertTransitAgentScope` below narrows them to the shipments
|
||||
// actually assigned to them.
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -1136,7 +1198,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertTransitAgentScope(id, user);
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@@ -1278,8 +1344,11 @@ export class BookingsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Was staff-only. Opened to the transit agent assigned to the shipment, who
|
||||
// needs the clearance trail for the bookings they handle; every other portal
|
||||
// account is still rejected by the scope check below.
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@@ -1287,7 +1356,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearanceHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
@@ -1310,6 +1383,12 @@ export class BookingsController {
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
// The transit agent handling this shipment sees the same customer-facing
|
||||
// slice the customer does — charges actually sent, never the internal
|
||||
// draft/billing view `list()` returns.
|
||||
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -1777,8 +1856,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Djibouti-desk write, also filed by the transit agent assigned to this
|
||||
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
|
||||
@Post(":id/clearance/delivery-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDeliveryOrder(
|
||||
@@ -1788,6 +1869,7 @@ export class BookingsController {
|
||||
@Body("doCollectedDate") doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1798,7 +1880,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/release-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingReleaseOrder(
|
||||
@@ -1807,6 +1889,7 @@ export class BookingsController {
|
||||
@Body("vesselDepartureDate") vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1821,12 +1904,13 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/ro-amendment")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
|
||||
@@ -1982,6 +1982,65 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `userId` is a transit agent currently assigned to this booking.
|
||||
*
|
||||
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
|
||||
* assertion guards ~29 call sites, including wagon cancellations, rebooking
|
||||
* and customer-truck writes. A transit agent must reach the clearance READS
|
||||
* for the shipments they handle and nothing else, so the two ownership rules
|
||||
* stay separate and each caller opts in explicitly.
|
||||
*
|
||||
* Queried directly rather than through TransitAssignmentsService: that module
|
||||
* imports BookingsModule, so injecting it here would close an import cycle.
|
||||
*/
|
||||
/** Is this portal account a transit agent at all? */
|
||||
async isTransitAgent(userId: string | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_agents a
|
||||
WHERE a.user_id = $1 AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async isTransitAgentForBooking(
|
||||
userId: string | undefined,
|
||||
bookingId: string,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_assignments ta
|
||||
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
|
||||
WHERE a.user_id = $1
|
||||
AND ta.booking_id = $2
|
||||
AND ta.deleted_at IS NULL
|
||||
AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId, bookingId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a clearance READ on one booking for either audience a portal
|
||||
* account can be: the owning customer, or a transit agent assigned to it.
|
||||
*
|
||||
* Read-only by contract — every caller is a GET. Writes keep using
|
||||
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
|
||||
*/
|
||||
async assertCanReadBookingClearance(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
|
||||
await this.assertCustomerCanAccessBooking(userId, booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
|
||||
@@ -116,6 +116,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
@@ -176,6 +177,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly yardScope: YardScopeService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
@@ -593,6 +595,17 @@ export class BookingClearanceService {
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// The booking only stores the officer's NAME, which is what the clearance
|
||||
// UI reads. The agent's own portal works off `transit_assignments` rows, so
|
||||
// without this the shipment never reaches the officer's work list — the
|
||||
// desk believes it handed the job over and nothing arrives.
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
@@ -185,6 +186,7 @@ export class ContractClearanceService {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -1230,6 +1232,18 @@ export class ContractClearanceService {
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
// Mirror the name onto the officer's own work list, exactly as the
|
||||
// per-booking path does. Contract-level clearance can be assigned before a
|
||||
// booking exists; in that case there is nothing for the officer to work on
|
||||
// yet, and the booking picks the assignment up when it is created.
|
||||
if (cycle.bookingId) {
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
cycle.bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
|
||||
return updated;
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
{} as never, // transitAssignmentsService
|
||||
{} as never, // dataSource
|
||||
);
|
||||
build([
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -1359,18 +1360,30 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
// Also filed by the transit agent assigned to the shipment — T1 is their own
|
||||
// transit paperwork. Any other portal caller is rejected below.
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
async uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@@ -1534,6 +1547,8 @@ export class ContractsController {
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
// Reads are open to both audiences (a transit agent assigned to the
|
||||
// shipment included); reporting an incident stays staff-only below.
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
|
||||
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
|
||||
import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
@@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||
ContractTemplatesModule,
|
||||
TransitAgentsModule,
|
||||
// Assigning a transit assignee must also land a row in the officer's own
|
||||
// work list. This module is a leaf (it registers Booking as an entity
|
||||
// rather than importing BookingsModule), so no cycle is closed here.
|
||||
TransitAssignmentsModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
forwardRef(() => BookingsModule),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
|
||||
import {
|
||||
GlExchangeService,
|
||||
@@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean =>
|
||||
@ApiBearerAuth()
|
||||
@Controller('gl-exchange')
|
||||
export class GlExchangeController {
|
||||
constructor(private readonly exchangeService: GlExchangeService) {}
|
||||
constructor(
|
||||
private readonly exchangeService: GlExchangeService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// Read opened to the transit agent assigned to the shipment; the POST/PATCH/
|
||||
// DELETE below stay staff-only, so an agent can read the desks' thread but
|
||||
// never post to it.
|
||||
@Get(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
|
||||
})
|
||||
list(
|
||||
async list(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
|
||||
hasFreightPermission(user, p),
|
||||
);
|
||||
if (
|
||||
!isStaff &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
entityId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Entity ${entityId} not found`);
|
||||
}
|
||||
return this.exchangeService.list(entityId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// Open to the transit agent assigned to the shipment as well as both desks:
|
||||
// the officer on the ground is often the one holding the scan either desk
|
||||
// needs. Their post is attributed to the TRANSIT side, never to a desk.
|
||||
@Post(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Share a document with the other GL desk' })
|
||||
upload(
|
||||
@ApiOperation({
|
||||
summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)',
|
||||
})
|
||||
async upload(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const actor = await this.resolveActor(entityId, user);
|
||||
return this.exchangeService.upload(
|
||||
entityId,
|
||||
file,
|
||||
{ title, visibleToCustomer: asBool(visibleToCustomer) },
|
||||
this.actor(user),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +144,36 @@ export class GlExchangeController {
|
||||
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
|
||||
* posts as Ethiopia.
|
||||
*/
|
||||
/**
|
||||
* Who is posting, for a route both desks and the assigned transit agent may
|
||||
* call. Staff keep the desk attribution below; a portal caller must be the
|
||||
* agent assigned to this shipment and posts as TRANSIT, so a document is
|
||||
* never credited to a desk that did not send it.
|
||||
*/
|
||||
private async resolveActor(
|
||||
entityId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<GlExchangeActor> {
|
||||
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
|
||||
hasFreightPermission(user, p),
|
||||
);
|
||||
if (isStaff) return this.actor(user);
|
||||
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
entityId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Entity ${entityId} not found`);
|
||||
}
|
||||
return {
|
||||
userId: resolveAuthUserId(user),
|
||||
name: actorLabel(user) ?? null,
|
||||
side: 'TRANSIT',
|
||||
};
|
||||
}
|
||||
|
||||
private actor(user: TCurrentUser): GlExchangeActor {
|
||||
const side: GlExchangeSide =
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity';
|
||||
*/
|
||||
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
||||
|
||||
export type GlExchangeSide = 'ET' | 'DJ';
|
||||
export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT';
|
||||
|
||||
export interface GlExchangeActor {
|
||||
userId: string;
|
||||
@@ -180,7 +180,14 @@ export class GlExchangeService {
|
||||
// Pre-title rows (none in practice) fall back to the filename so a list
|
||||
// never renders a blank row.
|
||||
title: record.title ?? record.name,
|
||||
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
||||
// `files.code` carries the poster's side. Anything unrecognised reads as
|
||||
// ET, which is how every pre-TRANSIT row was written.
|
||||
side:
|
||||
record.code === 'DJ'
|
||||
? 'DJ'
|
||||
: record.code === 'TRANSIT'
|
||||
? 'TRANSIT'
|
||||
: 'ET',
|
||||
visibleToCustomer: record.visibleToCustomer,
|
||||
uploadedById: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{} as never, // dataSource
|
||||
);
|
||||
});
|
||||
|
||||
@@ -244,6 +244,14 @@ export class TransitAssignmentsService {
|
||||
assignments: items.length,
|
||||
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
|
||||
.length,
|
||||
// The open half split by status, so the roster's tab counts do not have
|
||||
// to be derived from a single paginated page.
|
||||
notStarted: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.NotStarted,
|
||||
).length,
|
||||
inProgress: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.InProgress,
|
||||
).length,
|
||||
finished: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
@@ -393,6 +401,50 @@ export class TransitAssignmentsService {
|
||||
return this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `transitAgentId` the officer working `bookingId`, as the clearance
|
||||
* desk's "assign transit assignee" step means it.
|
||||
*
|
||||
* The booking itself only records the officer's NAME, which is all the
|
||||
* clearance UI needs; the officer's own portal reads `transit_assignments`.
|
||||
* This keeps the two in step, and is deliberately forgiving where `create()`
|
||||
* is strict:
|
||||
* - assigning the same agent twice is a no-op, not a 409 — the desk may
|
||||
* re-save the step without meaning to start over;
|
||||
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
|
||||
* not sit in the work list of someone who no longer handles it. Finished
|
||||
* rows stay, since they are that officer's record of work already done.
|
||||
*/
|
||||
async ensureAssignment(
|
||||
bookingId: string,
|
||||
transitAgentId: string,
|
||||
assignedByUserId?: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.assignmentsRepository.findByBooking(bookingId);
|
||||
|
||||
for (const row of existing) {
|
||||
if (
|
||||
row.transitAgentId !== transitAgentId &&
|
||||
row.status !== TransitAssignmentStatus.Finished
|
||||
) {
|
||||
await this.assignmentsRepository.softDelete(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.some((row) => row.transitAgentId === transitAgentId)) return;
|
||||
|
||||
await this.assignmentsRepository.create({
|
||||
bookingId,
|
||||
transitAgentId,
|
||||
status: TransitAssignmentStatus.NotStarted,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
assignedByUserId: assignedByUserId ?? null,
|
||||
note: null,
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTransitAssignmentDto,
|
||||
assignedByUserId?: string,
|
||||
|
||||
Reference in New Issue
Block a user