Merge pull request #1347 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-19 13:11:47 +03:00
committed by GitHub
18 changed files with 397 additions and 41 deletions

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 488 endpoints.
* Generated from the controllers under src/ — 517 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
@@ -83,6 +83,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"],
"POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
@@ -99,6 +102,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Chat
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
@@ -119,17 +125,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
@@ -222,6 +225,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
@@ -253,6 +257,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
@@ -301,6 +309,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
@@ -336,6 +345,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Logo Setting
"PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"],
"DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
@@ -377,6 +390,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
"POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"],
// Payment Setting
"PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
@@ -438,6 +455,20 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Shipping Line Booking
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
// Shipping Line Credit
"POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"],
"POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"],
// Shipping Line Company (carrier with a portal login, registered by staff)
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
@@ -445,6 +476,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Stamp Setting
"PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"],
"DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
@@ -515,9 +550,6 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
@@ -527,6 +559,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],

View File

@@ -940,8 +940,8 @@ export class BookingsController {
@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();
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
return this.bookingClearanceService.etQueue(user);
}
@Get('clearance/dj-queue')

View File

@@ -32,11 +32,14 @@ function makeService(overrides?: {
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
yardScope?: string[] | null;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -111,6 +114,7 @@ function makeService(overrides?: {
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
);
return {
@@ -124,6 +128,30 @@ function makeService(overrides?: {
}
describe('BookingClearanceService', () => {
describe('etQueue yard scope', () => {
const queueBookings = [
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
] as unknown as Booking[];
it('keeps only bookings whose origin or destination is in scope', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
});
it('shows everything when the position has no yard mapping', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows).toHaveLength(3);
});
});
describe('adviseDuty', () => {
it('skips duty milestones when duty is not required', async () => {
const { service, workflowService, bookingsRepository } = makeService();

View File

@@ -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 { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -158,6 +159,7 @@ export class BookingClearanceService {
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -981,7 +983,7 @@ export class BookingClearanceService {
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
async etQueue(user?: unknown): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
@@ -991,7 +993,26 @@ export class BookingClearanceService {
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return this.attachContractSummary(filtered);
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}
/**
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
* assigned yards (`freight.yard_positions` via the active position). Yards in
* the middle of a route do not count. An unmapped position, super admin or
* `yards:view_all` holder sees everything (scope resolves to `null`).
* Runs after {@link attachContractSummary} so route-fallback yards count too.
*/
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return bookings;
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
return bookings.filter(
(b) =>
inScope(b.originYardId ?? b.originYard?.id) ||
inScope(b.destinationYardId ?? b.destinationYard?.id),
);
}
/**

View File

@@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
// `routes` rides along so the queue can be narrowed to the caller's yards.
relations: { contract: { company: true, routes: true } },
});
}

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
@@ -28,6 +29,7 @@ export class BookingRequestService {
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -168,8 +170,25 @@ export class BookingRequestService {
return request;
}
queue(): Promise<BookingRequest[]> {
return this.repo.findQueue();
/**
* GL queue narrowed to the caller's yards: a request stays when its route's
* ORIGIN or DESTINATION yard is one the caller's active position is mapped to
* (unmapped position / super admin → everything). A request with no
* resolvable route (no `contractRouteId` on a multi-route contract) has no
* yards to judge by and is kept visible.
*/
async queue(user?: unknown): Promise<BookingRequest[]> {
const rows = await this.repo.findQueue();
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return rows;
return rows.filter((r) => {
const routes = r.contract?.routes ?? [];
const route =
routes.find((x) => x.id === r.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) return true;
return scope.includes(route.originYardId) || scope.includes(route.destinationYardId);
});
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

@@ -123,8 +123,8 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
bookingRequestQueue(@CurrentUser() user: AuthUserPayload) {
return this.bookingRequestService.queue(user);
}
@Get('booking-requests/:reqId')

View File

@@ -42,6 +42,7 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
])
@@ -107,6 +108,26 @@ export class TrainBuilderController {
return this.trainBuilderService.setYard(id, dto.currentYardId);
}
@Patch(':id/wagons/:wagonId/yard')
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
@ApiOperation({
summary:
'Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated',
})
setWagonYard(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@Body() dto: UpdateTrainYardDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.setWagonYard(
id,
wagonId,
dto.currentYardId,
resolveAuthUserId(user),
);
}
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -514,6 +514,42 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Move ONE coupled wagon to another yard (the train and the rest of the
* consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED)
* schedule has the wagon allocated to a slot — its standing yard is part of
* that schedule's route validation. Ledger row mirrors `setYard`.
*/
async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (wagon.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`,
);
}
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: wagon.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
});
return this.getComposition(id);
}
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
@@ -541,11 +577,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
@@ -582,11 +614,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
const previousStatus = wagon.status;
const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note);
await manager.getRepository(Wagon).update(wagon.id, {
@@ -669,6 +697,57 @@ export class TrainBuilderService {
return rows.length > 0;
}
/**
* Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly
* pinned only while a live schedule still NEEDS it: a slot carrying booking
* allocations, or any slot on a DISPATCHED run. An empty (allocation-free)
* slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was
* moved to another wagon (moveWagonLoad keeps the emptied slot) or its
* booking left through a path that didn't clean up — and used to pin the
* wagon forever. Release those slots here instead of blocking, with the
* same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters
* feed the schedule capacity math).
*/
private async assertDetachableAndReleaseStaleSlots(
manager: EntityManager,
wagon: Wagon,
): Promise<void> {
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
await manager.query(
`SELECT tsw.id, tsw.train_set_id, ts.status,
(SELECT count(*)
FROM freight.wagon_booking_allocations a
WHERE a.train_set_wagon_id = tsw.id
AND a.deleted_at IS NULL) AS allocs
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL`,
[wagon.id],
);
if (!rows.length) return;
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id));
for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) {
const remaining = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId },
select: { id: true, lengthMeters: true },
});
await manager.getRepository(TrainSet).update(trainSetId, {
wagonCount: remaining.length,
totalLengthMeters: round(
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
),
});
}
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {

View File

@@ -900,6 +900,11 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:trains:disband",
"Disband train",
),
perm(
"e1c00001-0001-4000-8000-000000000010",
"edr_freight_app:trains:change_wagon_yard",
"Change yard of a coupled wagon",
),
perm(
"e1d00001-0001-4000-8000-000000000001",
"edr_freight_app:routes:view",
@@ -2007,6 +2012,7 @@ export const FREIGHT_PERMS = {
assignWagons: "edr_freight_app:trains:assign_wagons",
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
@@ -2343,6 +2349,7 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.routes.view,

View File

@@ -6,11 +6,13 @@ import {
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { memo, useCallback, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { api } from "@/services/api";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import { wagonTypeColor } from "./trainStatus";
@@ -38,6 +40,7 @@ function ConsistWagonList({
onReorder,
onRemove,
onMaintenance,
onChangeYard,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = useCallback((result: DropResult) => {
@@ -107,6 +110,7 @@ function ConsistWagonList({
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
onChangeYard={onChangeYard}
/>
)}
</Draggable>
@@ -129,9 +133,68 @@ export interface ConsistWagonListProps {
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
onMaintenance: (wagon: TrainCompositionWagon) => void;
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
onChangeYard?: (wagonId: string, currentYardId: string) => void;
busy?: boolean;
}
/** Yard badge that opens a yard picker when `onChange` is provided. */
function WagonYardBadge({
wagon,
busy,
onChange,
}: {
wagon: TrainCompositionWagon;
busy: boolean;
onChange?: (wagonId: string, currentYardId: string) => void;
}) {
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
);
if (!onChange) {
return wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{label}
</Badge>
) : null;
}
return (
<Menu shadow="md" width={240} withinPortal>
<Menu.Target>
<Badge
component="button"
type="button"
variant="outline"
color="blue"
size="xs"
radius="sm"
leftSection={<MapPin size={10} />}
disabled={busy}
style={{ cursor: busy ? "default" : "pointer" }}
// Stop the drag handle from swallowing the click.
onMouseDown={(e) => e.stopPropagation()}
aria-label={`Change yard of wagon ${wagon.wagonNumber}`}
>
{label}
</Badge>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move wagon to yard</Menu.Label>
{(yardsQuery.data ?? []).map((y) => (
<Menu.Item
key={y.id}
disabled={y.id === wagon.currentYard?.id}
onClick={() => onChange(wagon.id, y.id)}
>
{y.label ?? y.code}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
const WagonRow = memo(function WagonRow({
wagon,
index,
@@ -141,6 +204,7 @@ const WagonRow = memo(function WagonRow({
busy,
onRemove,
onMaintenance,
onChangeYard,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -150,6 +214,7 @@ const WagonRow = memo(function WagonRow({
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
onChangeYard?: (wagonId: string, currentYardId: string) => void;
}) {
const color = wagonTypeColor(wagon.wagonType?.code);
@@ -195,11 +260,7 @@ const WagonRow = memo(function WagonRow({
{wagon.wagonType.code}
</Badge>
) : null}
{wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{wagon.currentYard.label ?? wagon.currentYard.code}
</Badge>
) : null}
<WagonYardBadge wagon={wagon} busy={busy} onChange={onChangeYard} />
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType

View File

@@ -218,6 +218,8 @@ export const FREIGHT_PERMS = {
/** Train-builder detail Actions menu — each item its own grant. */
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
/** Move ONE coupled wagon to another yard from the Wagon order list. */
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},

View File

@@ -94,6 +94,7 @@ export default function TrainBuilderDetailPage() {
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
@@ -109,6 +110,7 @@ export default function TrainBuilderDetailPage() {
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
@@ -162,6 +164,7 @@ export default function TrainBuilderDetailPage() {
const busy =
assignWagons.isPending ||
removeWagon.isPending ||
setWagonYard.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
@@ -217,6 +220,16 @@ export default function TrainBuilderDetailPage() {
},
[withToast, removeWagon.mutateAsync, trainId],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
if (!trainId) return;
void withToast(
() => setWagonYard.mutateAsync({ id: trainId, wagonId, currentYardId }),
"Could not change wagon yard",
);
},
[withToast, setWagonYard.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
@@ -499,6 +512,9 @@ export default function TrainBuilderDetailPage() {
onReorder={handleReorder}
onRemove={handleRemove}
onMaintenance={handleMaintenance}
onChangeYard={
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
}
/>
</Stack>
</Card>

View File

@@ -2132,6 +2132,19 @@ export const api = {
seedComposition,
),
setWagonYard: endpoint<
{ id: string; wagonId: string; currentYardId: string },
TrainComposition
>(
"train-builder",
"setWagonYard",
({ id, wagonId, currentYardId }) =>
trainBuilderService.setWagonYard(id, wagonId, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
"train-builder",
"removeWagon",

View File

@@ -315,6 +315,9 @@ export const trainBuilderService = {
/** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
/** Move one coupled wagon to another yard; the train stays put. */
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>

View File

@@ -57,6 +57,14 @@ export class CompleteVerificationResultDto {
agentId?: string;
};
@ApiPropertyOptional({
description:
'eSignet subject identifier for the verified individual (VERIFY flow). A PSUT — ' +
'pairwise and stable per client_id, never the FIN. The booking flow compares it across ' +
'passengers so one Fayda identity cannot verify more than one passenger on a booking.',
})
faydaSub?: string;
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
fullName?: string;

View File

@@ -70,11 +70,19 @@ export interface FaydaUserSummary {
/**
* Result of completing a verification. `verified` is always true on success.
* LOGIN additionally returns a JWT + user; VERIFY returns the verified identity
* attributes (name, email, phone, dob, gender) for the caller to consume.
* attributes (name, email, phone, dob, gender, faydaSub) for the caller to consume.
*/
export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
/**
* eSignet subject identifier for the verified individual. This is a PSUT —
* pairwise and stable per `client_id`, never the FIN — so it is safe to hand
* to the browser, and it is the same value `/passengers/me` already returns.
* The booking flow uses it to stop one Fayda identity from verifying more
* than one passenger on the same booking.
*/
faydaSub?: string;
token?: string;
refreshToken?: string;
requiresPassword?: boolean;
@@ -280,6 +288,7 @@ export class VerifaydaService {
result = {
purpose: 'VERIFY',
verified: true,
faydaSub: normalized.sub,
fullName: normalized.fullName,
email: normalized.email,
phoneNumber: normalized.phoneNumber,
@@ -357,6 +366,13 @@ export class VerifaydaService {
code_challenge_method: 'S256',
acr_values: this.faydaConfig.acrValues,
claims_locales: this.faydaConfig.claimsLocales,
// Force a fresh authentication instead of silently reusing the eSignet
// SSO session. A booking can carry several passengers, each of whom must
// verify with their OWN Fayda; without this, the second and third
// "Verify with Fayda" clicks round-trip in a couple of seconds and hand
// back the first passenger's identity, which the booking flow then has to
// reject with no way for the user to authenticate as the right person.
prompt: 'login',
});
// Every claim is marked essential so eSignet shows them locked/pre-checked

View File

@@ -761,6 +761,10 @@ function PassengersForm() {
passportExpiryDate: stored.passportExpiryDate || '',
passportIssuingAuthority: stored.passportIssuingAuthority || '',
faydaVerified: stored.faydaVerified || false,
// Restore the identity that verified this passenger, so returning here from a
// later step (e.g. Back from /booking/seats) doesn't silently reopen the slot to
// an already-used Fayda.
faydaSub: stored.faydaSub || undefined,
formExpanded: true,
};
}
@@ -873,13 +877,22 @@ function PassengersForm() {
if (d?.verified) {
const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin;
// A single Fayda identity can't be reused across two different passengers.
const usedByOther = faydaSub && passengers.some(
(p, i) => i !== targetIndex && (p as any).faydaSub === faydaSub,
);
// A single Fayda identity can't be reused across two different passengers. Read the
// live form rather than the `passengers` captured when this effect was created — the
// snapshot restore repopulates the array as the form initializes.
const currentPassengers = watch('passengers') || [];
const conflictIndex = faydaSub
? currentPassengers.findIndex(
(p, i) => i !== targetIndex && (p as any)?.faydaSub === faydaSub,
)
: -1;
if (usedByOther) {
setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'This Fayda identity is already linked to another passenger on this booking.' }));
if (conflictIndex >= 0) {
const conflictName = currentPassengers[conflictIndex]?.name?.trim();
setFaydaErrors((prev) => ({
...prev,
[targetIndex]: `This Fayda ID has already been used to verify Passenger ${conflictIndex + 1}${conflictName ? ` (${conflictName})` : ''}. Each traveller must verify with their own Fayda.`,
}));
setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' }));
} else {
// Convert "1980/12/01" → "1980-12-01"
@@ -989,6 +1002,13 @@ function PassengersForm() {
const emailVal = pick(passengerData.email, user.email);
if (emailVal) setValue('passengers.0.email', emailVal);
// A logged-in, already-verified user occupies slot 0 without going through a fresh
// Fayda round trip, so the callback never records their sub on the form. Seed it from
// the account here, otherwise the duplicate-identity check has nothing to compare
// against and the account holder can re-use their own Fayda on passenger 2.
const accountFaydaSub = pick(passengerData.faydaSub, (user as any).faydaSub);
if (accountFaydaSub) setValue('passengers.0.faydaSub', accountFaydaSub);
if (mustVerifyFayda) {
// Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the
// "Verify with Fayda" screen is shown instead of an editable, pre-filled form.
@@ -1103,6 +1123,13 @@ function PassengersForm() {
gender: p.gender,
nationality: p.nationality,
nationalId: p.nationalId,
// Carry the verified Fayda identity into the booking store so the duplicate-identity
// check still has it if the user comes back to this page from /booking/seats. Without
// it the restore path below rebuilds each passenger without a sub, and one Fayda could
// then re-verify every passenger. Stripped server-side by the global ValidationPipe
// (whitelist: true), so sending it to /passengers/save-details is a no-op there.
faydaVerified: p.faydaVerified,
faydaSub: p.faydaSub,
passportNumber: p.passportNumber,
passportCountry: p.passportCountry,
passportIssueDate: p.passportIssueDate,