diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 71107650f..5cdb0d6a4 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
arrivalAt: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
+ /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
+ status: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -263,9 +265,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
- * occupies. Handed to the customer when EDR accepts the cargo (export) and when
- * the wagons are allocated before marshalling (import), so it is only available
- * once the booking has wagon allocations.
+ * occupies. A booking is routinely loaded in parts (some containers go, the
+ * rest wait for the next train), so each row carries a Status of Loaded or
+ * Not loaded and the totals count only the loaded ones: the customer sees the
+ * whole plan on one page without the sheet overstating what EDR has taken.
+ *
+ * Handed to the customer when EDR accepts the cargo (export) and when the
+ * wagons are allocated before marshalling (import).
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
@@ -285,6 +291,7 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
+ a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -299,7 +306,7 @@ export class BookingsService {
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
- GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
+ GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`,
[bookingId],
@@ -383,6 +390,8 @@ export class BookingsService {
arrivalAt: null,
containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null,
+ // A received line has no allocation; it is cargo EDR already holds.
+ status: null,
}));
}
@@ -497,7 +506,17 @@ export class BookingsService {
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
- const totals = wagons.reduce(
+ // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
+ // totals count only those: the sheet shows the whole plan, but must never
+ // total up cargo still sitting in the yard. A received-line sheet
+ // (pendingWagons) has no allocation status, and every line on it is cargo
+ // already accepted, so it counts in full.
+ const isLoaded = (w: CarriageAcceptanceWagonRow) =>
+ pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
+ const loadedWagons = wagons.filter(isLoaded);
+ const notLoadedCount = wagons.length - loadedWagons.length;
+
+ const totals = loadedWagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +526,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
- const fullWagons = wagons.filter(
+ const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
@@ -525,6 +544,9 @@ export class BookingsService {
${esc(departureStation)}
${esc(w.containerNumbers)}
${esc(w.sealNumbers)}
+ ${
+ pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
+ }
${money(prices[i])}
`,
)
@@ -535,11 +557,11 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `
TOT
- ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}
+ ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}
${
pendingWagons
? 'pending marshalling'
- : `full ${fullWagons} / empty ${wagons.length - fullWagons}`
+ : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}
${num(totals.tare, 2)}
${num(totals.length)}
@@ -549,6 +571,7 @@ export class BookingsService {
+ ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}
${money(totalAmount)}
`;
@@ -575,6 +598,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
+ .loaded { color: #0f766e; font-weight: 700; }
+ .pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +643,7 @@ export class BookingsService {
Departure Station
Container No.
Seal No.
+ Status
Price (${esc(currency)})
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
index 477724839..10b6afd37 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
@@ -176,6 +176,18 @@ export class ContractNotifierService {
this.inApp(c, 'Contract suspension lifted', msg);
}
+ /**
+ * Backoffice cancelled the contract. Terminal — the customer is told they may
+ * submit a new contract with the same details if they still need the service.
+ */
+ cancelledByStaff(c: Contract, reason: string): void {
+ const msg =
+ `Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
+ `If you still need this service you can submit a new contract request with the same details.`;
+ void this.notifyContact(c, msg, 'CANCELLED');
+ this.inApp(c, 'Contract cancelled', msg);
+ }
+
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts
index 96a8a26ac..c535c909e 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts
@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
]);
});
});
+
+describe('contract base freight ignores shipping-line rates', () => {
+ it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
+ // Both LIVE on the contract's own lane: the line rate sorted first and won,
+ // so the contract quoted 32 USD/wagon instead of the standard 1690.
+ const breakdown = await service([
+ rate({
+ containerTypeId: CT20,
+ rateValue: 32,
+ rateUnit: 'PER_WAGON',
+ shippingLineCompanyId: 'line-1',
+ }),
+ rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
+ ]).buildBreakdown(contract({}));
+ expect(breakdown.lineItems).toEqual([
+ expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
+ ]);
+ });
+
+ it('blocks when the only rate on the lane belongs to a shipping line', async () => {
+ await expect(
+ service([
+ rate({
+ containerTypeId: CT20,
+ rateValue: 32,
+ shippingLineCompanyId: 'line-1',
+ }),
+ ]).buildBreakdown(contract({})),
+ ).rejects.toThrow(UnprocessableEntityException);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
index 0af09a5f8..04be6460f 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
@@ -85,7 +85,15 @@ export class ContractPricingService {
* commodity rate) — NO totals or quantities (doc §9.1).
*/
async buildBreakdown(contract: Contract): Promise {
- const liveRates = await this.ratesService.findLiveRates();
+ // Contracts belong to a customer company — there is no shipping-line
+ // contract (no shipping_line_company_id on the entity), so a contract may
+ // only ever price off the standard rates. Without this filter a line's
+ // negotiated rate on the same lane matched first and the contract froze it
+ // for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
+ // 23 USD/container 40ft instead of the standard 1690 / 1676.
+ const liveRates = (await this.ratesService.findLiveRates()).filter(
+ (r) => !r.shippingLineCompanyId,
+ );
const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts
new file mode 100644
index 000000000..06a406bf1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts
@@ -0,0 +1,113 @@
+import { ContractTransitionService } from './contract-transition.service';
+import type { Contract } from './entities/contract.entity';
+
+/**
+ * Staff cancel is terminal, so the rules that matter are: it needs its own
+ * permission (suspend must NOT imply it), it refuses to strand live shipments,
+ * it works on a suspended contract, and it cannot be applied twice.
+ */
+describe('ContractTransitionService — staff cancel', () => {
+ const contract = (over: Partial = {}): Contract =>
+ ({
+ id: 'c-1',
+ reference: 'CTR-2026-00042',
+ companyId: 'co-1',
+ status: 'CONTRACT_ACTIVE',
+ freightType: 'CONTAINER',
+ ...over,
+ }) as Contract;
+
+ let current: Contract;
+ let repo: {
+ update: jest.Mock;
+ createReviewNote: jest.Mock;
+ countActiveBookings: jest.Mock;
+ };
+ let notifier: { cancelledByStaff: jest.Mock };
+ let service: ContractTransitionService;
+
+ const staff = {
+ permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
+ };
+
+ beforeEach(() => {
+ current = contract();
+ repo = {
+ update: jest.fn().mockImplementation((_id: string, patch: object) => {
+ current = { ...current, ...patch } as Contract;
+ return Promise.resolve(current);
+ }),
+ createReviewNote: jest.fn().mockResolvedValue(undefined),
+ countActiveBookings: jest.fn().mockResolvedValue(0),
+ };
+ notifier = { cancelledByStaff: jest.fn() };
+ service = Object.create(
+ ContractTransitionService.prototype,
+ ) as ContractTransitionService;
+ Object.assign(service, {
+ contractsRepository: repo,
+ contractsService: { findById: () => Promise.resolve(current) },
+ notifier,
+ });
+ });
+
+ it('cancels, records the reason as a staff note, and notifies the customer', async () => {
+ await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
+
+ expect(repo.update).toHaveBeenCalledWith('c-1', {
+ status: 'CANCELLED',
+ statusBeforeSuspension: null,
+ });
+ expect(repo.createReviewNote).toHaveBeenCalledWith(
+ 'c-1',
+ 'Duplicate request',
+ 'CANCELLATION',
+ 'staff-1',
+ 'STAFF',
+ );
+ expect(notifier.cancelledByStaff).toHaveBeenCalled();
+ });
+
+ it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
+ current = contract({
+ status: 'SUSPENDED',
+ statusBeforeSuspension: 'CONTRACT_ACTIVE',
+ } as Partial);
+
+ await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
+
+ expect(repo.update).toHaveBeenCalledWith('c-1', {
+ status: 'CANCELLED',
+ statusBeforeSuspension: null,
+ });
+ });
+
+ it('refuses while a shipment is still running', async () => {
+ repo.countActiveBookings.mockResolvedValue(2);
+
+ await expect(
+ service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
+ ).rejects.toThrow('2 active shipments');
+ expect(repo.update).not.toHaveBeenCalled();
+ });
+
+ it('refuses to cancel an already-terminal contract', async () => {
+ current = contract({ status: 'CANCELLED' });
+
+ await expect(
+ service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
+ ).rejects.toThrow(/already cancelled/i);
+ expect(repo.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
+ const suspender = {
+ permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
+ };
+
+ await expect(
+ service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
+ ).rejects.toThrow();
+ expect(repo.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index 936176f86..ac400d9d3 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
return updated;
}
+ /**
+ * Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
+ * with the same parameters can be submitted afterwards (references are minted
+ * per contract, so nothing about the old row blocks the new one).
+ *
+ * Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
+ * contract is exactly the one staff most often need to kill outright.
+ */
+ async cancelByStaff(
+ contractId: string,
+ reason: string,
+ actorId: string,
+ user?: TCurrentUser | null,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
+ if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
+ throw new ConflictException(
+ `Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
+ );
+ }
+
+ // Same guard as the customer path: live shipments must be settled first,
+ // otherwise cancelling the contract orphans cargo already in motion.
+ const active = await this.contractsRepository.countActiveBookings(contractId);
+ if (active > 0) {
+ throw new BadRequestException(
+ `This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
+ 'Cancel or complete them before cancelling the contract.',
+ );
+ }
+
+ await this.contractsRepository.createReviewNote(
+ contractId,
+ reason,
+ 'CANCELLATION',
+ actorId,
+ 'STAFF',
+ );
+ await this.contractsRepository.update(contractId, {
+ status: 'CANCELLED',
+ statusBeforeSuspension: null,
+ } as never);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.cancelledByStaff(updated, reason);
+ return updated;
+ }
+
async renew(contractId: string, userId?: string): Promise {
const source = await this.contractsService.findById(contractId);
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index 1dc083934..c38576fff 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -72,6 +72,7 @@ import {
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
+ CancelContractByStaffDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -552,6 +553,25 @@ export class ContractsController {
);
}
+ @Post(':id/staff/cancel')
+ @BookingStaff(FREIGHT_PERMS.contracts.cancel)
+ @ApiOperation({
+ summary:
+ 'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
+ })
+ cancelByStaff(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: CancelContractByStaffDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.transitionService.cancelByStaff(
+ id,
+ dto.reason,
+ resolveAuthUserId(user),
+ user,
+ );
+ }
+
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts
index 6aa36b13d..63057978c 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts
@@ -51,6 +51,14 @@ export class CancelContractDto {
reason?: string;
}
+/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
+export class CancelContractByStaffDto {
+ @ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()
diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
index 467b172ba..1dd8ad9ca 100644
--- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
+++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
}
}
+
+/** One container line on the load manifest notice. */
+interface LoadManifestLists {
+ reference: string;
+ companyId: string | null;
+ trainNumber: string | null;
+ originStation: string | null;
+ destinationStation: string | null;
+ departureAt: Date | null;
+ loaded: string[];
+ leftBehind: string[];
+}
+
+/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
+function summarizeNumbers(numbers: string[], max = 5): string {
+ if (numbers.length === 0) return 'none';
+ const shown = numbers.slice(0, max).join(', ');
+ const rest = numbers.length - max;
+ return rest > 0 ? `${shown} +${rest} more` : shown;
+}
+
+/**
+ * Read what actually went on the train and what did not. Left behind = every
+ * container the customer declared minus the ones sitting on a LOADED/DEPARTED
+ * wagon, so a booking loaded in parts reports honestly on both halves.
+ */
+export async function loadManifestLists(
+ dataSource: DataSource,
+ bookingId: string,
+ trainScheduleId: string,
+): Promise {
+ const [booking]: Array<{ reference: string; companyId: string | null }> =
+ await dataSource.query(
+ `SELECT reference, company_id AS "companyId"
+ FROM freight.bookings
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [bookingId],
+ );
+ if (!booking) return null;
+
+ const [train]: Array<{
+ trainNumber: string | null;
+ originStation: string | null;
+ destinationStation: string | null;
+ departureAt: Date | null;
+ }> = await dataSource.query(
+ `SELECT s.train_number AS "trainNumber",
+ so.label AS "originStation",
+ sd.label AS "destinationStation",
+ s.scheduled_departure_date AS "departureAt"
+ FROM freight.train_schedules s
+ LEFT JOIN freight.yards so ON so.id = s.origin_station_id
+ LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
+ WHERE s.id = $1 AND s.deleted_at IS NULL`,
+ [trainScheduleId],
+ );
+
+ const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
+ `SELECT DISTINCT ci.container_number AS "containerNumber"
+ FROM freight.wagon_allocation_container_items ci
+ JOIN freight.wagon_booking_allocations a
+ ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
+ WHERE a.booking_id = $1
+ AND ci.deleted_at IS NULL
+ AND a.status IN ('LOADED', 'DEPARTED')
+ ORDER BY 1`,
+ [bookingId],
+ );
+ const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
+ `SELECT DISTINCT u.container_number AS "containerNumber"
+ FROM freight.booking_container_units u
+ JOIN freight.booking_container l
+ ON l.id = u.booking_container_id AND l.deleted_at IS NULL
+ WHERE l.booking_id = $1 AND u.deleted_at IS NULL
+ ORDER BY 1`,
+ [bookingId],
+ );
+
+ const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
+ const loadedSet = new Set(loaded);
+ const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
+ (n) => !loadedSet.has(n),
+ );
+
+ return {
+ reference: booking.reference,
+ companyId: booking.companyId,
+ trainNumber: train?.trainNumber ?? null,
+ originStation: train?.originStation ?? null,
+ destinationStation: train?.destinationStation ?? null,
+ departureAt: train?.departureAt ?? null,
+ loaded,
+ leftBehind,
+ };
+}
+
+/**
+ * Tell the customer what boarded the train and what did not, over in-app + SMS
+ * + email, and raise a warehouse-desk notice for anything left behind so
+ * somebody owns finding it space. A booking is routinely loaded in parts, and
+ * before this the customer learnt about it only by reading the sheet.
+ *
+ * Best-effort throughout: loading must never roll back because a provider is
+ * down.
+ */
+export async function notifyLoadManifest(
+ dataSource: DataSource,
+ notifications: NotificationsService,
+ inbox: NotificationInboxService,
+ bookingId: string,
+ trainScheduleId: string,
+ warehouseNotificationPermission: string,
+ logger: Logger,
+): Promise {
+ try {
+ const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
+ if (!m) return;
+
+ const route =
+ m.originStation && m.destinationStation
+ ? ` ${m.originStation} → ${m.destinationStation}`
+ : '';
+ const departs = m.departureAt
+ ? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
+ : '';
+ const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
+
+ const headline =
+ `Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
+ `${route}${departs}.`;
+ const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
+ const leftLine =
+ m.leftBehind.length > 0
+ ? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
+ ' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
+ : '';
+ const body = headline + loadedLine + leftLine;
+
+ if (m.companyId) {
+ await inbox.notify({
+ recipients: { companyId: m.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.BOOKING_STATUS,
+ title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
+ // The in-app copy carries every number; SMS and email get the summary.
+ body:
+ headline +
+ (m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
+ (m.leftBehind.length > 0
+ ? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
+ : ''),
+ link: `/bookings/${bookingId}`,
+ data: {
+ bookingId,
+ reference: m.reference,
+ trainNumber: m.trainNumber,
+ loaded: m.loaded,
+ leftBehind: m.leftBehind,
+ },
+ });
+ await sendCompanyChannels(dataSource, notifications, m.companyId, body);
+ }
+
+ // Nothing left behind is nothing for the warehouse desk to place.
+ if (m.leftBehind.length > 0) {
+ await inbox.notify({
+ recipients: { permissionKeys: [warehouseNotificationPermission] },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
+ body:
+ `${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
+ `${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
+ link: `/dashboard/booking-requests/${bookingId}`,
+ data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
+ });
+ }
+ } catch (err) {
+ logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
index 3fbecc036..f035f8a64 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
@@ -508,12 +508,12 @@ export class RuleEngineService {
if (input.tradeDirection === 'IMPORT') {
appliedModifiers.push(
- ...this.derivedImportOverweight(
+ ...(await this.derivedImportOverweight(
input,
containerWeightResults,
lineMaxVgmTons,
liveRates,
- ),
+ )),
);
}
@@ -540,23 +540,37 @@ export class RuleEngineService {
}
/**
- * Import overweight — derived, never configured. Each overweight container
- * line bills its excess tons at (its own base import freight on the booking's
- * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
- * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
- * Note: derives from the LIVE route rate even for frozen-rate contract
- * bookings — the frozen snapshot has no route-scoped container price to
- * divide.
+ * Import overweight — derived, never configured. Excess tons are billed on a
+ * PER-WAGON basis: (the wagon's base import freight on the booking's route)
+ * ÷ (2 × the container's weight limit).
+ *
+ * The rate is normalised to a wagon before dividing, because a 20ft rate
+ * quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
+ * while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
+ * doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
+ * - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
+ * - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
+ * Halving over 2 × the limit keeps the original meaning: filling one wagon's
+ * worth of excess costs one extra wagon of freight.
+ *
+ * Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE
+ * route rate even for frozen-rate contract bookings — the frozen snapshot has
+ * no route-scoped container price to divide.
*/
- private derivedImportOverweight(
+ private async derivedImportOverweight(
input: BookingEvaluationInput,
weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array,
liveRates: Rate[],
- ): AppliedCargoModifier[] {
+ ): Promise {
const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers;
+ // How many of each container type ride one wagon: a 40ft fills a wagon,
+ // two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
+ // be scaled up to the wagon the overweight formula prices against.
+ const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
+
for (let i = 0; i < weightResults.length; i++) {
const wr = weightResults[i];
const excess = Number(wr?.overweightExcessTons ?? 0);
@@ -578,7 +592,16 @@ export class RuleEngineService {
// No base rate → the base-freight line hard-blocks this booking anyway.
if (!base) continue;
- const perTon = Number(base.rateValue) / (2 * maxVgm);
+ // Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
+ // rate covers half a wagon, so it is scaled by the 2 containers that ride
+ // one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
+ // price already — never scale it again.
+ const perWagonRate =
+ base.rateUnit === 'PER_CONTAINER'
+ ? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
+ : Number(base.rateValue);
+
+ const perTon = perWagonRate / (2 * maxVgm);
const amount = excess * perTon;
if (!(amount > 0)) continue;
@@ -595,6 +618,42 @@ export class RuleEngineService {
return modifiers;
}
+ /**
+ * Containers of each type that ride ONE wagon, derived from the type's
+ * size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
+ * caller actually needs are looked up. Unknown or non-positive sizes fall
+ * back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
+ * behaviour, so a missing size can never inflate a bill.
+ */
+ private async containersPerWagonByTypeId(
+ weightResults: ContainerWeightResult[],
+ ): Promise> {
+ const perWagon = new Map();
+ const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))];
+ if (ids.length === 0) return perWagon;
+
+ let rows: Array<{ id: string; size_ft: string | number | null }> = [];
+ try {
+ rows = await this.dataSource.query(
+ 'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)',
+ [ids],
+ );
+ } catch {
+ // Size lookup unavailable — fall back to an unscaled (×1) rate, the
+ // behaviour before per-wagon normalisation. Never fail pricing over it.
+ return perWagon;
+ }
+ const WAGON_SLOT_FT = 40;
+ for (const row of rows) {
+ const sizeFt = Number(row.size_ft ?? 0);
+ perWagon.set(
+ row.id,
+ sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1,
+ );
+ }
+ return perWagon;
+ }
+
/**
* Empty-container return — sold per direction + route + container type, like
* base freight. Each container line that opted in (returnQuantity, or every
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
index 07de05853..46cbdb2f6 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
-import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
+import {
+ notifyCarriageAcceptanceReady,
+ notifyLoadManifest,
+} from '../notifications/notify-company.util';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -267,6 +271,20 @@ export class BookingJourneyService {
});
});
+ // What actually boarded, and what did not. A booking is routinely loaded in
+ // parts; the customer is told both halves, and the warehouse desk is told
+ // about the leftovers so somebody owns placing them. After the transaction:
+ // the lists are read back from the allocation statuses it just wrote.
+ void notifyLoadManifest(
+ this.dataSource,
+ this.notifications,
+ this.inbox,
+ bookingId,
+ scheduleId,
+ FREIGHT_PERMS.warehouseInventory.getNotification,
+ this.logger,
+ );
+
// Customer tracking: cargo is on the train — loading milestones plus the
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
// bookings (intercity) and already-completed codes.
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
index d6e778216..64af1a56f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)');
});
- it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
+ it('drops a leg slot entirely from the import document — not part of the departing consist', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
- expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
- // Departure station of the leg slot is its board yard, not the origin.
- expect(html).toContain('Dire Dawa Port ');
- // Only the origin-loaded container counts; the leg slot's tallies separately.
+ // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
+ // it isn't on the departing consist. Only W-IMP appears.
+ expect(html).not.toContain('W-ICY');
+ expect(html).not.toContain('ICY-001');
+ expect(html).toContain('W-IMP');
+ expect(html).toContain('Wagons 1 ');
expect(html).toContain('Total containers 1 ');
- expect(html).toContain('To load en route 1 containers ');
});
- it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
+ it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
const sizedAllocation = {
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
});
- expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
+ // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all.
+ expect(html).not.toContain('W-LEG');
+ expect(html).toContain('Wagons 1 ');
expect(html).toContain('Total containers 1 ');
- expect(html).toContain('To load en route 1 containers ');
});
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
@@ -1240,6 +1242,44 @@ describe('TrainSchedulingService', () => {
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
});
+ it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => {
+ const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' };
+ const legSlot = {
+ ...makeWagon(2, 'W-LEG', [loadedAllocation]),
+ id: 'slot-leg',
+ boardYardId: 'yard-dire',
+ alightYardId: 'yard-adama',
+ };
+ const schedule = {
+ id: 'schedule-1',
+ trainNumber: '8302',
+ direction: 'EXPORT',
+ originStation: { label: 'DCT/SGTD' },
+ destinationStation: { label: 'GMP (Gelan Multipurpose Port)' },
+ trainSet: { wagons: [wholeRoute, legSlot] },
+ scheduleBookings: [],
+ };
+ const build = (service as never as {
+ buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
+ }).buildExportLoadListHtml.bind(service);
+
+ const html = build(schedule, {
+ yardLabelById: new Map([
+ ['yard-dire', 'Dire Dawa Port'],
+ ['yard-adama', 'Adama'],
+ ]),
+ });
+
+ expect(html).toContain('Departure Station ');
+ expect(html).toContain('Arrival Station ');
+ // Whole-route wagon: schedule's own endpoints.
+ expect(html).toContain('DCT/SGTD ');
+ expect(html).toContain('GMP (Gelan Multipurpose Port) ');
+ // Leg slot: its own board/alight yard, not the schedule's endpoints.
+ expect(html).toContain('Dire Dawa Port ');
+ expect(html).toContain('Adama ');
+ });
+
it('lists loaded empty containers by number and states they are empty', () => {
const schedule = {
id: 'schedule-1',
@@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => {
expect(numbers).toEqual(['W-LEG2']);
});
+ it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
+ // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
+ // at Adama, a LATER stop) read identically to intercityOnBoardView by
+ // the time this runs — both LOADED right now. Only the adjustment log
+ // knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp.
+ const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
+ const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' };
+ const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' };
+ const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] };
+
+ const { wagons } = onBoardView(schedule);
+ const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop
+ const wagonsAsOfStop = (service as never as {
+ wagonsAsOfStop: (w: unknown, s: Set) => Array<{ physicalWagon: { wagonNumber: string } }>;
+ }).wagonsAsOfStop.bind(service);
+
+ const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa);
+ expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']);
+
+ const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened
+ const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama);
+ expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']);
+ });
+
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
const rider = {
id: 'booking-9',
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
index 19140d822..07d386df3 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
@@ -28,6 +28,7 @@ import {
ILike,
In,
IsNull,
+ LessThanOrEqual,
Not,
QueryFailedError,
Raw,
@@ -3556,17 +3557,19 @@ export class TrainSchedulingService {
// Leg slots couple mid-corridor — this origin document must say where their
// cargo boards instead of listing it as loaded here (see the import list).
- const slotYardLabels = await this.yardLabelsById(
- (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
+ // Also doubles as the per-row Departure/Arrival Station lookup below.
+ const yardLabelById = await this.yardLabelsById(
+ (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
);
const pendingBoardYardLabelBySlot = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.boardYardId)
- .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
+ .map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']),
);
const html = this.buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot,
+ yardLabelById,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
@@ -3623,6 +3626,24 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
+ /**
+ * Corrects intercityOnBoardView's CURRENT-state wagon list against a
+ * specific stop's document. intercityOnBoardView's "boardYardId == null ||
+ * hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
+ * "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
+ * that has, by generation time, also already happened" (both look LOADED).
+ * Reprinting an earlier stop's document after a later one has run would
+ * otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
+ * set of physical wagon numbers with a logged ADD at or before this stop
+ * (see marshallingDocumentAt) — the ground truth a real-time heuristic
+ * can't provide once multiple stops have already happened.
+ */
+ private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set): TrainSetWagon[] {
+ return wagons.filter(
+ (wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
+ );
+ }
+
/**
* Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train
@@ -3717,11 +3738,37 @@ export class TrainSchedulingService {
);
}
- const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
+ const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
+ // intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
+ // CURRENT state — it can't tell "coupled here" from "coupled at a LATER
+ // stop that has since also happened" (both look LOADED by generation
+ // time once the trip has moved past this stop). Reprinting Marshalling 2
+ // after Marshalling 3's stop already ran would otherwise show Marshalling
+ // 3's coupled wagons too. Correct it against the log: a leg-slot wagon
+ // belongs on THIS stop's document only if it actually has a logged ADD
+ // at or before THIS stop's own timestamp.
+ const boardedByThisStop = new Set(
+ (
+ await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
+ where: {
+ trainScheduleId: scheduleId,
+ action: 'ADD',
+ occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
+ },
+ })
+ ).map((row) => row.wagonNumber),
+ );
+ const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' },
});
+ // Per-row Departure/Arrival Station: a whole-route wagon reads the
+ // schedule's own origin/destination, a leg-slot wagon reads where IT
+ // boards/alights instead.
+ const yardLabelById = await this.yardLabelsById(
+ (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
+ );
const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`,
@@ -3730,6 +3777,7 @@ export class TrainSchedulingService {
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
+ yardLabelById,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
@@ -3769,6 +3817,9 @@ export class TrainSchedulingService {
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
+ const yardLabelById = await this.yardLabelsById(
+ (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
+ );
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
@@ -3776,6 +3827,7 @@ export class TrainSchedulingService {
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
+ yardLabelById,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3845,6 +3897,10 @@ export class TrainSchedulingService {
// Slots that couple to the train downstream (slot id → board yard label).
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map;
+ // yardId → label, for the per-row Departure/Arrival Station columns
+ // (falls back to the schedule's own origin/destination when a wagon's
+ // boardYardId/alightYardId is null — i.e. it rides the whole corridor).
+ yardLabelById?: Map;
// Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box.
@@ -3866,10 +3922,15 @@ export class TrainSchedulingService {
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in
- // consist order — the relation comes back unordered.
- const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
- (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
- );
+ // consist order — the relation comes back unordered. Slots planned to
+ // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
+ // intercity calls never pass it, their wagons list is already on-board
+ // only) are dropped here, not just tallied around: they are not part of
+ // the departing consist, so they get no row and no count on this document.
+ // Their own coupling shows up on THAT stop's own marshalling document.
+ const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
+ .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
+ .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded.
const emptiesByWagon = new Map();
@@ -3880,15 +3941,28 @@ export class TrainSchedulingService {
empty,
]);
}
+ const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
+ const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
const rows = wagons
.flatMap((wagon) => {
+ // Departure/Arrival Station per row: a leg-slot wagon boards/alights
+ // somewhere other than the schedule's own endpoints; a whole-route
+ // wagon just reads origin/destination.
+ const departureLabel = wagon.boardYardId
+ ? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route')
+ : originLabel;
+ const arrivalLabel = wagon.alightYardId
+ ? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route')
+ : destinationLabel;
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `${esc(wagon.sequenceNo)}
${esc(wagon.physicalWagon?.wagonNumber)}
${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}
${esc(Number(wagon.lengthMeters || 0).toFixed(3))}
${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}
- ${esc(Number(wagon.capacityTons || 0).toFixed(3))} `;
+ ${esc(Number(wagon.capacityTons || 0).toFixed(3))}
+ ${esc(departureLabel)}
+ ${esc(arrivalLabel)} `;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
@@ -3912,11 +3986,10 @@ export class TrainSchedulingService {
return [
`
${wagonCells}
- EMPTY — no cargo allocated
+ EMPTY — no cargo allocated
`,
];
}
- const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
@@ -3928,7 +4001,7 @@ export class TrainSchedulingService {
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `
${wagonCells}
- ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}
+ ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}
${esc(companyName)}
${esc(containerNumbers || firstContainer?.containerNumber)}
${esc(chassisNumbers)}
@@ -3941,7 +4014,7 @@ export class TrainSchedulingService {
// they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length
- ? ` ON BOARD — WAGON NOT RECORDED ` +
+ ? `ON BOARD — WAGON NOT RECORDED ` +
unassigned
.map((booking) => {
const containerNumbers = (booking.bookingContainers ?? [])
@@ -3950,7 +4023,7 @@ export class TrainSchedulingService {
.join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `
- ${esc(booking.reference)} — ${esc(leg)}
+ ${esc(booking.reference)} — ${esc(leg)}
${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}
${esc(booking.company?.name)}
${esc(containerNumbers)}
@@ -3965,27 +4038,20 @@ export class TrainSchedulingService {
(wagon.allocations ?? []).length === 0 &&
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
).length;
- const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
const totalWeight = wagons.reduce(
(sum, wagon) =>
- sum +
- (loadsHere(wagon)
- ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
- : 0),
+ sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
- // physically on the train, so they count, and are called out on their own tile.
- // Cargo boarding downstream is not on this train yet — it tallies separately.
- let count40ft = 0, count20ft = 0, pendingContainers = 0;
+ // physically on the train, so they count, and are called out on their own
+ // tile. Cargo boarding downstream never enters this loop — `wagons` above
+ // already excludes those slots.
+ let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
- if (!loadsHere(wagon)) {
- pendingContainers++;
- return;
- }
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
@@ -4053,7 +4119,6 @@ export class TrainSchedulingService {
Containers 40ft ${esc(count40ft)}
Containers 20ft ${esc(count20ft)}
Total containers ${esc(count40ft + count20ft)}
- ${pendingContainers ? `To load en route ${esc(pendingContainers)} containers
` : ''}
${emptyContainers.length ? `Empty containers ${esc(emptyContainers.length)}
` : ''}
Prepared person ${esc(schedule.preparedByUserId)}
Check person ${esc(schedule.checkedByUserId)}
@@ -4099,6 +4164,8 @@ export class TrainSchedulingService {
Equated Length
Tare Weight
Load Capacity
+ Departure Station
+ Arrival Station
Cargo Type
Company
Container No
@@ -4107,7 +4174,7 @@ export class TrainSchedulingService {
- ${rows || 'No wagons on this train set. '}
+ ${rows || 'No wagons on this train set. '}
${unassignedRows}
@@ -4253,30 +4320,23 @@ export class TrainSchedulingService {
.replace(/'/g, ''');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
- // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
- // physical train this Djibouti-side document is checked against, so it must
- // stay out of the loaded tallies or the gate count stops matching.
- const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
- const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
- const totalWeight = loadList.wagons.reduce(
- (sum, wagon) =>
- sum +
- (loadsHere(wagon)
- ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
- : 0),
+ // A leg slot (boardYard set) couples mid-corridor — it is not part of the
+ // consist this Djibouti-side document is checked against yet, so it gets
+ // no row and no count here at all. Its own coupling shows up on THAT
+ // stop's own marshalling document once it actually happens.
+ const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
+ const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
+ const totalWeight = wagons.reduce(
+ (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
- const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
+ const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
- // Container count summary (40ft, 20ft) — loaded at origin vs. en route
- let count40ft = 0, count20ft = 0, pendingContainers = 0;
- loadList.wagons.forEach((wagon) => {
+ // Container count summary (40ft, 20ft)
+ let count40ft = 0, count20ft = 0;
+ wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
- if (!loadsHere(wagon)) {
- pendingContainers++;
- return;
- }
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
@@ -4284,7 +4344,7 @@ export class TrainSchedulingService {
});
});
- const allocationRows = loadList.wagons
+ const allocationRows = wagons
.flatMap((wagon) => {
const wagonCells = `${esc(wagon.sequenceNo)}
${esc(wagon.wagonNumber)}
@@ -4317,7 +4377,7 @@ export class TrainSchedulingService {
${esc(allocation.loadType)}
${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}
${esc(sealNumbers || '-')}
- ${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}
+
${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}
`;
},
@@ -4384,13 +4444,12 @@ export class TrainSchedulingService {
Origin ${esc(loadList.origin)}
Destination ${esc(loadList.destination)}
Total bookings ${esc(loadList.totalBookings)}
- Wagons ${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
+ Wagons ${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations ${esc(totalAllocations)}
Total weight ${esc(totalWeight.toFixed(3))} T
Containers 40ft ${esc(count40ft)}
Containers 20ft ${esc(count20ft)}
Total containers ${esc(count40ft + count20ft)}
- ${pendingContainers ? `To load en route ${esc(pendingContainers)} containers
` : ''}
Gatepass granted ${esc(date(loadList.operation.gatepassGrantedAt))}
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
index 7b28a8582..26c29499d 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
@@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
+import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
@@ -504,7 +505,7 @@ export class TrainBuilderService {
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
- await this.dataSource.transaction(async (manager) => {
+ const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const yard = await manager
.getRepository(Yard)
@@ -523,10 +524,76 @@ export class TrainBuilderService {
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
+ // Capacity math reads the SET's locomotives, not the train's — push the
+ // new pull weight onto the live runs too, or they keep the old ceiling.
+ return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds);
});
+ // Re-derive FULL/reopen once committed — a bigger pull weight can free room
+ // on a schedule that had closed as FULL.
+ await this.reconcileWindowsAfterConsistChange(pending);
return this.getComposition(id);
}
+ /**
+ * Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED)
+ * schedule formed from it. The three capacity axes are derived from
+ * `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted
+ * when the set is built and never re-synced — so adding a second locomotive
+ * raised `trains.capacity_tons` but left every existing schedule pulling on
+ * the old single-loco ceiling, still refusing bookings for want of weight.
+ *
+ * Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render
+ * from their frozen snapshot and must not be disturbed (same rule as
+ * syncLiveScheduleAfterConsistChange).
+ */
+ private async syncLiveSchedulesAfterLocomotiveChange(
+ manager: EntityManager,
+ trainId: string,
+ locomotiveIds: string[],
+ ): Promise {
+ const trainSets = await manager.getRepository(TrainSet).find({ where: { trainId } });
+ if (!trainSets.length) return [];
+
+ const schedules = await manager.getRepository(TrainSchedule).find({
+ where: {
+ trainSetId: In(trainSets.map((s) => s.id)),
+ status: In(['DRAFT', 'SCHEDULED']),
+ },
+ });
+ if (!schedules.length) return [];
+
+ // Only the sets still backing a live run — a set behind an ARRIVED schedule
+ // keeps the locomotives it actually ran with.
+ const liveSetIds = [...new Set(schedules.map((s) => s.trainSetId))];
+ const [primaryId] = locomotiveIds;
+ for (const trainSetId of liveSetIds) {
+ await manager.getRepository(TrainSetLocomotive).delete({ trainSetId });
+ await manager.getRepository(TrainSetLocomotive).save(
+ locomotiveIds.map((locomotiveId, index) =>
+ manager
+ .getRepository(TrainSetLocomotive)
+ .create({ trainSetId, locomotiveId, sequenceNo: index }),
+ ),
+ );
+ // `locomotiveId` is the primary-locomotive fallback for single-loco reads.
+ await manager.getRepository(TrainSet).update(trainSetId, { locomotiveId: primaryId });
+ }
+
+ return schedules.map((s) => ({
+ scheduleId: s.id,
+ wasFull: s.bookingWindowStatus === 'FULL',
+ }));
+ }
+
+ /** {@link reconcileWindowAfterConsistChange} over several schedules. */
+ private async reconcileWindowsAfterConsistChange(
+ pending: PendingWindowCheck[],
+ ): Promise {
+ for (const check of pending) {
+ await this.reconcileWindowAfterConsistChange(check);
+ }
+ }
+
/**
* Edit a built train's display identity: name and fixed import/export run
* numbers. Mirrors the build-time number rules — the pair may not collide
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index aa032c17d..43e7d7ae8 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
+ bookingId?: string | null;
};
booking: {
companyId?: string | null;
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
+ bookingId,
},
booking,
bookingId,
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
grnNumber,
direction: bookingDirection,
warehouseId: dto.warehouseId,
+ bookingId: dto.bookingId ?? null,
});
return saved.id;
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
+ /** Resolves the company, which unlocks in-app + email alongside the SMS. */
+ bookingId?: string | null;
}): Promise {
- const phone = params.phone?.trim();
- if (!phone) return;
-
const ownerName = params.ownerName?.trim() || 'Customer';
const bookingReference = params.bookingReference?.trim();
const message =
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
(params.direction ? `Direction: ${params.direction}. ` : '') +
`Thank you.`;
+ // A booking gives us the company, and with it the customer's inbox and
+ // email — not just whatever phone number the gate clerk typed. Without one
+ // (manual or backlog receive) the typed phone is all there is, so the
+ // original SMS-only path stands.
+ let companyId: string | null = null;
+ if (params.bookingId) {
+ try {
+ const [row]: Array<{ companyId: string | null }> = await this.dataSource.query(
+ `SELECT company_id AS "companyId"
+ FROM freight.bookings
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [params.bookingId],
+ );
+ companyId = row?.companyId ?? null;
+ } catch (error) {
+ this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`);
+ }
+ }
+
+ if (companyId) {
+ try {
+ await this.inbox.notify({
+ recipients: { companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.DOCUMENT_ACTION,
+ title: 'Cargo received — GRN issued',
+ body: message,
+ link: params.bookingId ? `/bookings/${params.bookingId}` : undefined,
+ data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null },
+ });
+ // Sends SMS *and* email to the company's own contacts, so the typed
+ // phone below is skipped to avoid texting the customer twice.
+ await sendCompanyChannels(this.dataSource, this.notifications, companyId, message);
+ return;
+ } catch (error) {
+ this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`);
+ }
+ }
+
+ const phone = params.phone?.trim();
+ if (!phone) return;
try {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 90a7310c8..2a8ac578e 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:contracts:suspend",
"Suspend / resume a signed contract",
),
+ // Terminal kill switch. Unlike suspend this cannot be undone — the customer
+ // re-submits a fresh contract with the same parameters instead.
+ perm(
+ "a3000001-0001-4000-8000-00000000001c",
+ "edr_freight_app:contracts:cancel",
+ "Cancel a contract (terminal)",
+ ),
];
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
@@ -1909,6 +1916,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:additional_charges:get_notification",
"Receive additional charge notifications",
),
+ perm(
+ "f3a00001-0001-4000-8000-00000000000a",
+ "edr_freight_app:warehouse_inventory:get_notification",
+ "Receive warehouse desk notifications (containers left behind at loading)",
+ ),
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
@@ -2061,6 +2073,7 @@ export const FREIGHT_PERMS = {
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
suspend: "edr_freight_app:contracts:suspend",
+ cancel: "edr_freight_app:contracts:cancel",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
@@ -2375,6 +2388,12 @@ export const FREIGHT_PERMS = {
release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver",
inspect: "edr_freight_app:warehouse_inventory:inspect",
+ /**
+ * Notification selector, not a route guard — who gets pinged when cargo is
+ * left behind at loading and needs warehouse space. Assign it to whichever
+ * desk owns that; it grants access to nothing.
+ */
+ getNotification: "edr_freight_app:warehouse_inventory:get_notification",
},
interchangeDocuments: {
view: "edr_freight_app:interchange_documents:view",
@@ -2865,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.generateContract,
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
FREIGHT_PERMS.contracts.suspend,
+ // Terminal kill switch, granted alongside suspend on the same desk that
+ // already rejects contracts and cancels bookings.
+ FREIGHT_PERMS.contracts.cancel,
FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
// Marketing follows up with the customer when a reviewer sends profile
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
index d2a245f24..be70ffe4d 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
+ Ban,
Check,
Eye,
// FilePen, // ponytail: back with the "Edit contract articles" button
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
// One key both ways — whoever can freeze a contract can unfreeze it.
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
+ // Cancel is its own key — it is terminal, so it is NOT implied by suspend.
+ const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
const [suspendReason, setSuspendReason] = useState("");
const [resumeOpen, setResumeOpen] = useState(false);
const [resumeNote, setResumeNote] = useState("");
+ const [cancelOpen, setCancelOpen] = useState(false);
+ const [cancelReason, setCancelReason] = useState("");
+
+ // Shared by the suspended branch and the normal toolbar — both can cancel.
+ const cancelModal = (
+ setCancelOpen(false)}
+ title="Cancel this contract?"
+ centered
+ >
+
+
+ Contract {contract.reference} will be cancelled permanently.
+ This cannot be undone — there is no way to reactivate it. A new
+ contract with the same details can be submitted afterwards. The
+ customer is notified.
+
+
+
+ );
+
+ const cancelButton = mayCancel ? (
+ }
+ onClick={() => setCancelOpen(true)}
+ >
+ Cancel contract
+
+ ) : null;
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
if (status === "CHANGES_REQUESTED") {
return (
-
- No staff actions until the customer resubmits the contract.
-
+
+
+ No staff actions until the customer resubmits the contract.
+
+ {cancelButton}
+
+ {cancelModal}
);
}
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
You do not have permission to lift a suspension.
)}
+ {cancelButton}
+
+ {cancelModal}
);
}
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
)}
+ {/* Available at every non-terminal status — the early returns above
+ already cover the statuses where cancelling makes no sense. */}
+ {cancelButton}
+
{!canAccept &&
!inApproval &&
!canViewContract &&
!canReviewClearance &&
- !canSuspend && (
+ !canSuspend &&
+ !mayCancel && (
No staff actions available for this status. Monitor until the
workflow advances.
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
+
+ {cancelModal}
);
}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index cd3f1e99e..d09b9f4c5 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
STAFF_REQUEST_CHANGES: (id: string) =>
`/contracts/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
+ STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
RESUME: (id: string) => `/contracts/${id}/resume`,
APPROVE_STEP: (id: string, stepId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
index bc0697996..3ee56f916 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
+ const cancelByStaff = useMutation({
+ mutationFn: (reason: string) =>
+ contractsService.cancelByStaff(contractId, reason),
+ onSuccess: (data) => onSuccess(data, "Contract cancelled"),
+ onError: (error) =>
+ toast.error(extractErrorMessage(error, "Failed to cancel contract")),
+ });
+
const suspend = useMutation({
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract suspended"),
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
updateDocument,
requestChanges,
reject,
+ cancelByStaff,
suspend,
resume,
approveStep,
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index 38af63785..62137496f 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
+ cancel: "edr_freight_app:contracts:cancel",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 88ac1218d..c372f5829 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -40,6 +40,7 @@ import {
} from "@mantine/core";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
+import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
a.click();
URL.revokeObjectURL(url);
} catch (error) {
- toast.error(
- error instanceof Error
- ? error.message
- : "Carriage acceptance sheet is not available yet",
- );
+ // Blob response: the JSON reason is inside the Blob, so
+ // the sync path would show only "status code 400".
+ toast.error(await extractDownloadErrorMessage(error));
}
}}
>
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index 3d9152d11..c06e280dd 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -288,6 +288,13 @@ export const contractsService = {
reject: (id: string, reason: string) =>
postContract(C.STAFF_REJECT(id), { reason }),
+ /**
+ * Cancel a contract outright. Terminal — unlike {@link suspend} there is no
+ * way back; a new contract with the same details must be submitted instead.
+ */
+ cancelByStaff: (id: string, reason: string) =>
+ postContract(C.STAFF_CANCEL(id), { reason }),
+
/** Freeze a signed contract. Reversible — see {@link resume}. */
suspend: (id: string, reason: string) =>
postContract(C.SUSPEND(id), { reason }),
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
index 46c19ea7a..25f601628 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
+/**
+ * A `responseType: "blob"` request delivers its JSON error body as a Blob, so
+ * reading `error.message` gives "Request failed with status code 400" instead
+ * of the reason. Read the blob back before falling back.
+ */
+async function downloadErrorMessage(error: unknown, fallback: string): Promise {
+ const data = (error as { response?: { data?: unknown } })?.response?.data;
+ if (data instanceof Blob) {
+ try {
+ const parsed = JSON.parse(await data.text()) as { message?: unknown };
+ if (parsed?.message) return String(parsed.message);
+ } catch {
+ /* not JSON — fall through */
+ }
+ }
+ return error instanceof Error ? error.message : fallback;
+}
+
+
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
// One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false);
+
+ // The carriage acceptance sheet is its own document, not warehouse paperwork:
+ // direct truck-to-train cargo never sees a warehouse, and this sheet IS its
+ // handover record. Hiding it inside the warehouse bundle made it unfindable.
+ const [casBusy, setCasBusy] = useState(false);
+ const downloadCarriageAcceptance = async () => {
+ setCasBusy(true);
+ const ref = booking.reference ?? booking.id;
+ try {
+ const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
+ saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
+ } catch (error) {
+ toast.error(
+ await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
+ );
+ } finally {
+ setCasBusy(false);
+ }
+ };
const downloadWarehouseDocuments = async () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
)}
+ {/* ── Carriage acceptance sheet (its own document) ────────────────── */}
+
+ Carriage acceptance sheet
+
+ The record of the cargo EDR has accepted for carriage, listing each wagon and the
+ containers on it, and marking which have been loaded.
+
+ }
+ color="edr-green"
+ variant="light"
+ loading={casBusy}
+ onClick={downloadCarriageAcceptance}
+ >
+ Download sheet
+
+
+
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
Warehouse documents
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
index ecd3b4043..36f2b7250 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
@@ -22,7 +22,7 @@ import {
ApiBody,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
-import { BookingsService } from "./bookings.service";
+import { BookingsService, BookingScope } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
+const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
+
@ApiTags("Booking")
@Controller("bookings")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
@@ -66,6 +68,13 @@ export class BookingsController {
required: false,
description: "Filter by booking status",
})
+ @ApiQuery({
+ name: "scope",
+ required: false,
+ enum: ["upcoming", "past", "cancelled", "all"],
+ description:
+ "Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.",
+ })
@ApiQuery({ name: "page", required: false, description: "Page number" })
@ApiQuery({
name: "pageSize",
@@ -80,6 +89,7 @@ export class BookingsController {
@Req() req: any,
@Query("search") search?: string,
@Query("status") status?: string,
+ @Query("scope") scope?: BookingScope,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
@@ -88,6 +98,7 @@ export class BookingsController {
return this.service.findByIamUserId(iamUserId, {
search,
status,
+ scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 7a447690a..b627d64ba 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
-import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
+import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -67,8 +67,15 @@ interface BookingFilters {
dateTo?: string;
page?: number;
pageSize?: number;
+ /** Portal "My bookings" tabs. Only honoured by findByPassengerId. */
+ scope?: BookingScope;
}
+export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
+
+/** Statuses that mean the reservation is off — used by the `cancelled` scope. */
+const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED'];
+
@Injectable()
export class BookingsService {
private readonly logger = new Logger(BookingsService.name);
@@ -87,16 +94,34 @@ export class BookingsService {
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
- const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
+ // An IAM user with no Passenger row is normal, not an error: a freshly registered
+ // account that has never booked, or a staff account. findUniqueOrThrow raised P2025
+ // here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
+ const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
+ if (!passenger) {
+ const page = filters.page ?? 1;
+ const pageSize = filters.pageSize ?? 20;
+ return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
+ }
return this.findByPassengerId(passenger.id, filters);
}
+ /**
+ * The portal's authenticated "My bookings" history (GET /bookings/my).
+ *
+ * `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
+ * correctly, rather than the client filtering one page at a time. Note it filters on
+ * `schedule.departureAt` — the schedule's own origin departure — while each item's
+ * displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own
+ * boarding stop. They differ by the run time to that stop; that is close enough for a
+ * tab filter and avoids a correlated stopTimes query per row.
+ */
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
- const { search, status, page = 1, pageSize = 20 } = filters;
+ const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
-
+
const where: any = { passengerId };
-
+
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
@@ -104,22 +129,41 @@ export class BookingsService {
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
}
-
- if (status) {
+
+ // `status` used to be forwarded raw, so an unrecognised value threw a Prisma
+ // validation error (a 500) rather than being ignored. Only accept real enum members.
+ if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
where.status = status;
}
-
+
+ const now = new Date();
+ let orderBy: any = { createdAt: 'desc' };
+ if (scope === 'cancelled') {
+ where.status = { in: CLOSED_BOOKING_STATUSES };
+ } else if (scope === 'upcoming' || scope === 'past') {
+ // Don't clobber an explicit `status` filter — intersect with it.
+ if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
+ where.schedule = {
+ ...(where.schedule ?? {}),
+ departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
+ };
+ orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
+ }
+
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
take: pageSize,
- orderBy: { createdAt: 'desc' },
+ orderBy,
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
- seats: { include: { seat: true } },
+ seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
priceTier: { select: { priceMinor: true } },
+ // A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the
+ // portal needs this to show a "Rescheduled" chip alongside the real status.
+ reschedules: { where: { status: 'APPLIED' }, select: { id: true } },
},
}),
this.prisma.booking.count({ where }),
@@ -150,6 +194,21 @@ export class BookingsService {
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
+ // Seat/coach per passenger, so the history table can show a Seat / Coach column
+ // without a round trip to GET /bookings/:ref for every row. `leg` disambiguates
+ // outbound (1) from return (2) on a round trip.
+ seats: booking.seats.map((bs: any) => ({
+ leg: bs.leg ?? 1,
+ passengerName: bs.passengerName,
+ seatNumber: bs.seat?.seatNumber ?? null,
+ coachNumber: bs.seat?.coach?.number ?? null,
+ })),
+ rescheduled: ((booking as any).reschedules?.length ?? 0) > 0,
+ // These three let the portal apply the same coarse reschedule gate the booking
+ // detail page uses, without fetching each booking in full.
+ outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
+ isPackageBooking: !!(booking as any).packageId,
+ contactPhone: (booking as any).contactPhone ?? null,
};
}),
meta: {
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
index 123cd5710..1fbe4080e 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
@@ -6,6 +6,7 @@ import { useState } from "react";
import { apiClient } from "@/lib/api-client";
import { format } from "date-fns";
import { toZonedDate } from "@/utils/format";
+import { STATUS_LABELS } from "@/lib/api/bookings";
type SearchMode = "pnr" | "phone";
@@ -30,15 +31,6 @@ interface BookingListItem {
seatCount: number;
}
-const STATUS_LABELS: Record = {
- CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
- PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
- CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
- BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
- NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" },
- REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" },
-};
-
export default function BookingLookupPage() {
const router = useRouter();
const [mode, setMode] = useState("pnr");
diff --git a/apps/edr-passenger-web/portal/src/app/bookings/page.tsx b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx
new file mode 100644
index 000000000..6092e4cb2
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { Loader2, Search } from 'lucide-react';
+import { useAuthStore } from '@/lib/auth-store';
+import MyBookingsTable from '@/components/MyBookingsTable';
+
+/**
+ * "My Bookings" for a signed-in customer — every booking on the account, without the
+ * BRN / phone lookup a guest has to go through at /booking/lookup.
+ *
+ * The portal's middleware does no auth gating (it only sets the CSP nonce), so pages
+ * self-check. Same shape as /profile and /booking/reschedule.
+ */
+export default function MyBookingsPage() {
+ const router = useRouter();
+ const { isAuthenticated, isInitialized, initialize } = useAuthStore();
+
+ useEffect(() => {
+ initialize();
+ }, [initialize]);
+
+ useEffect(() => {
+ if (isInitialized && !isAuthenticated) {
+ router.push('/login?redirect=/bookings');
+ }
+ }, [isInitialized, isAuthenticated, router]);
+
+ if (!isInitialized || !isAuthenticated) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
My Bookings
+
+ Every trip booked on this account.
+
+
+ {/* A customer can still hold a booking made under a different phone number as a
+ guest — that one is only reachable by reference, so keep the door open. */}
+
+
+ Look up another booking
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
index 37ffdabfb..ef21631b1 100644
--- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx
@@ -4,32 +4,19 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider';
-import {
- User, Settings, Ticket, Calendar, MapPin,
- Download, Trash2, Lock, Bell, CreditCard,
+import {
+ User, Settings, Ticket,
+ Download, Trash2, Lock, Bell,
MapPinned, Palette, CheckCircle,
- Eye, Edit, LogOut, X
+ Edit, LogOut, X
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useMutation } from '@tanstack/react-query';
import CustomModal from '@/components/CustomModal';
+import MyBookingsTable from '@/components/MyBookingsTable';
type Tab = 'bookings' | 'profile' | 'settings';
-interface Booking {
- id: string;
- pnr: string;
- status: string;
- totalMinor: number;
- createdAt: string;
- trip?: {
- trainNumber: string;
- departureAt: string;
- origin?: { name: string };
- destination?: { name: string };
- };
-}
-
export default function ProfilePage() {
const router = useRouter();
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
@@ -82,18 +69,6 @@ export default function ProfilePage() {
}
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
- const { data: bookings, isLoading: loadingBookings } = useQuery({
- queryKey: ['user-bookings'],
- queryFn: async () => {
- try {
- return await apiClient.get('/bookings/my-bookings');
- } catch {
- return [];
- }
- },
- enabled: isAuthenticated && activeTab === 'bookings',
- });
-
const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
onSuccess: (response) => {
@@ -229,16 +204,6 @@ export default function ProfilePage() {
setShowModal(true);
};
- const getStatusBadge = (status: string) => {
- const styles = {
- CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
- PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
- CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
- COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
- };
- return styles[status as keyof typeof styles] || styles.PENDING;
- };
-
if (!isInitialized || !user) {
return (
@@ -322,71 +287,11 @@ export default function ProfilePage() {
{activeTab === 'bookings' && (
Bookings
-
- {loadingBookings ? (
-
-
-
Loading bookings...
-
- ) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
- bookings.map((booking: Booking) => (
-
-
-
-
-
- {booking.status}
-
-
- PNR: {booking.pnr}
-
-
-
-
-
-
-
- {booking.trip?.departureAt
- ? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' })
- : 'N/A'}
-
-
-
-
-
- {booking.trip?.origin?.name} → {booking.trip?.destination?.name}
-
-
-
-
-
- ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
-
-
-
-
-
-
- router.push(`/booking/confirmation?id=${booking.id}`)}
- className="btn-secondary text-sm flex items-center gap-2"
- >
-
- View
-
-
-
-
- ))
- ) : (
-
-
-
No bookings yet
-
router.push('/booking/search')} className="btn-primary">
- Book Your First Trip
-
-
- )}
+ {/* Same component as /bookings, so the two never drift. It replaces a card
+ list that called GET /bookings/my-bookings — a route that does not exist
+ (the real one is GET /bookings/my), whose 404 was swallowed, so this tab
+ always read "No bookings yet". */}
+
)}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
index a621c5d83..7565712ba 100644
--- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
@@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record
= {
'/booking/confirmation': 'confirmation',
};
-const NAV_LINKS = [
+// "My Bookings" resolves differently by session: a signed-in customer gets their own
+// account history at /bookings, a guest gets the BRN / phone lookup form. Same label
+// either way, because it is the same intent.
+const navLinks = (isAuthenticated: boolean) => [
{ href: '/', label: 'Home', icon: Home },
- { href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
+ { href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket },
{ href: '/contact', label: 'Contact', icon: Phone },
{ href: '/help', label: 'Help', icon: HelpCircle },
];
@@ -83,7 +86,7 @@ export default function AppSidebar() {
- {NAV_LINKS.map(({ href, label, icon: Icon }) => {
+ {navLinks(isAuthenticated).map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
return (
p === '/' },
- { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
+ // Signed in: straight to the account's own history. Signed out: the guest lookup form.
+ {
+ href: isAuthenticated ? '/bookings' : '/booking/lookup',
+ label: 'Bookings',
+ icon: Ticket,
+ match: (p: string) =>
+ p.startsWith('/bookings') || p.startsWith('/booking/lookup') || p.startsWith('/booking/detail'),
+ },
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
{
href: isAuthenticated ? '/profile' : '/login',
diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
new file mode 100644
index 000000000..2d3cf7d64
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
@@ -0,0 +1,379 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import {
+ Ticket,
+ Loader2,
+ ChevronLeft,
+ ChevronRight,
+ Clock,
+ Eye,
+ CreditCard,
+ RefreshCw,
+ AlertCircle,
+} from 'lucide-react';
+import { toZonedDate } from '@/utils/format';
+import { samePhone } from '@/utils/phone';
+import { useAuthStore } from '@/lib/auth-store';
+import {
+ fetchMyBookings,
+ statusBadge,
+ type BookingScope,
+ type MyBookingItem,
+} from '@/lib/api/bookings';
+
+const PAGE_SIZE = 10;
+
+const SCOPES: { id: BookingScope; label: string }[] = [
+ { id: 'upcoming', label: 'Upcoming' },
+ { id: 'past', label: 'Past' },
+ { id: 'cancelled', label: 'Cancelled' },
+];
+
+const EMPTY_COPY: Record = {
+ upcoming: 'No upcoming trips.',
+ past: 'No past trips yet.',
+ cancelled: 'No cancelled bookings.',
+ all: 'No bookings yet.',
+};
+
+function formatTravelDate(iso: string) {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '—';
+ return format(toZonedDate(d), 'dd MMM yyyy, HH:mm');
+}
+
+/**
+ * "CRS-0002 · 12, 14" for one leg. Round trips carry leg-2 seats on the same booking.
+ * Coach.number is already a full code on this data (e.g. "CRS-0002"), so it is printed
+ * as-is rather than prefixed.
+ */
+function describeSeats(seats: MyBookingItem['seats'], leg: number) {
+ const forLeg = (seats ?? []).filter((s) => (s.leg ?? 1) === leg);
+ const numbers = forLeg.map((s) => s.seatNumber).filter(Boolean);
+ if (numbers.length === 0) return null;
+ const coaches = Array.from(new Set(forLeg.map((s) => s.coachNumber).filter(Boolean)));
+ const coachLabel = coaches.length > 0 ? `${coaches.join(' / ')} · ` : '';
+ return `${coachLabel}${numbers.join(', ')}`;
+}
+
+interface RowActions {
+ canReschedule: boolean;
+ rescheduleBlocker: string | null;
+ isPendingPayment: boolean;
+}
+
+/**
+ * The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules
+ * (fare-class policy, cutoff, seats still free) belong to the reschedule page, which
+ * names them as blockers — this only avoids sending the customer somewhere that is
+ * certain to reject them. The phone test matches the API's own ownership check
+ * (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based.
+ */
+function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
+ const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
+
+ let rescheduleBlocker: string | null = null;
+ if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled';
+ else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online';
+ else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
+ rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
+ else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
+ else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
+ rescheduleBlocker = 'Only the person who made this booking can reschedule it';
+
+ return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
+}
+
+/**
+ * The signed-in customer's own booking history, from GET /bookings/my — no BRN or
+ * phone lookup. Rendered by /bookings and by the /profile Bookings tab, so both stay
+ * one implementation.
+ */
+export default function MyBookingsTable() {
+ const router = useRouter();
+ const userPhone = useAuthStore((s) => s.user?.phone);
+ const [scope, setScope] = useState('upcoming');
+ const [page, setPage] = useState(1);
+
+ const { data, isLoading, isError, refetch, isFetching } = useQuery({
+ queryKey: ['my-bookings', scope, page],
+ queryFn: () => fetchMyBookings({ scope, page, pageSize: PAGE_SIZE }),
+ // Keeps the current rows on screen while a tab or page change is in flight instead
+ // of collapsing to the spinner and jumping the scroll position.
+ placeholderData: keepPreviousData,
+ });
+
+ const items = data?.items ?? [];
+ const meta = data?.meta;
+ const totalPages = meta?.totalPages ?? 0;
+
+ const switchScope = (next: BookingScope) => {
+ setScope(next);
+ setPage(1);
+ };
+
+ const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
+ const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
+
+ const cardClass =
+ 'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
+
+ return (
+
+ {/* Scope tabs */}
+
+ {SCOPES.map(({ id, label }) => (
+ switchScope(id)}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ scope === id
+ ? 'bg-[rgb(20,113,76)] text-white'
+ : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
+ }`}
+ >
+ {label}
+
+ ))}
+ {isFetching && !isLoading && (
+
+ )}
+
+
+ {isLoading ? (
+
+
+
Loading your bookings…
+
+ ) : isError ? (
+
+
+
+ We could not load your bookings just now.
+
+
refetch()}
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm font-medium"
+ >
+
+ Try again
+
+
+ ) : items.length === 0 ? (
+
+
+
{EMPTY_COPY[scope]}
+
router.push('/booking/search')}
+ className="px-4 py-2 rounded-lg bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white text-sm font-medium transition-colors"
+ >
+ Book a trip
+
+
+ ) : (
+ <>
+ {/* Desktop: the tabular view */}
+
+
+
+
+ Booking Ref
+ Travel date & time
+ Route
+ Seat / Coach
+ Status
+ Actions
+
+
+
+ {items.map((b) => {
+ const badge = statusBadge(b.status);
+ const actions = resolveActions(b, userPhone);
+ const outbound = describeSeats(b.seats, 1);
+ const inbound = describeSeats(b.seats, 2);
+ return (
+
+
+ {b.bookingRef}
+
+
+ {formatTravelDate(b.schedule.departureAt)}
+
+
+ {b.schedule.originStation.name} → {b.schedule.destinationStation.name}
+ {b.bookingType === 'ROUND_TRIP' && (
+
+ (round trip)
+
+ )}
+
+
+ {outbound ?? — }
+ {inbound && (
+
+ Return: {inbound}
+
+ )}
+
+
+
+ {badge.label}
+
+ {b.rescheduled && (
+
+ Rescheduled
+
+ )}
+
+
+
+ openDetail(b)}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-medium transition-colors whitespace-nowrap"
+ >
+ {actions.isPendingPayment ? (
+ <>
+
+ Complete payment
+ >
+ ) : (
+ <>
+
+ View ticket
+ >
+ )}
+
+ {!actions.isPendingPayment && (
+ openReschedule(b)}
+ disabled={!actions.canReschedule}
+ title={
+ actions.rescheduleBlocker ??
+ 'Change the date, train or seats on this booking'
+ }
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
+ >
+
+ Reschedule
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Mobile: the same rows as cards — the portal's established list pattern */}
+
+ {items.map((b) => {
+ const badge = statusBadge(b.status);
+ const actions = resolveActions(b, userPhone);
+ const outbound = describeSeats(b.seats, 1);
+ const inbound = describeSeats(b.seats, 2);
+ return (
+
+
+
+ {b.bookingRef}
+
+
+
+ {badge.label}
+
+ {b.rescheduled && (
+
+ Rescheduled
+
+ )}
+
+
+
+
+ {b.schedule.originStation.name} → {b.schedule.destinationStation.name}
+
+
+ {formatTravelDate(b.schedule.departureAt)}
+
+
+ Seat / Coach: {outbound ?? '—'}
+ {inbound && ` · Return: ${inbound}`}
+
+
+
+ openDetail(b)}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 text-xs font-medium"
+ >
+ {actions.isPendingPayment ? (
+ <>
+
+ Complete payment
+ >
+ ) : (
+ <>
+
+ View ticket
+ >
+ )}
+
+ {!actions.isPendingPayment && (
+ openReschedule(b)}
+ disabled={!actions.canReschedule}
+ title={
+ actions.rescheduleBlocker ??
+ 'Change the date, train or seats on this booking'
+ }
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
+ >
+
+ Reschedule
+
+ )}
+
+
+ );
+ })}
+
+
+ {totalPages > 1 && (
+
+
+ Page {meta?.page ?? page} of {totalPages} · {meta?.total ?? items.length} booking
+ {(meta?.total ?? items.length) !== 1 ? 's' : ''}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page <= 1}
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
+ >
+
+ Previous
+
+ setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page >= totalPages}
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
+ >
+ Next
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/lib/api/bookings.ts b/apps/edr-passenger-web/portal/src/lib/api/bookings.ts
new file mode 100644
index 000000000..d87cd97f9
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/lib/api/bookings.ts
@@ -0,0 +1,94 @@
+import { apiClient } from '@/lib/api-client';
+
+/**
+ * The authenticated booking history: GET /bookings/my (JwtGuard). This is the
+ * account-linked list — no BRN or phone lookup — as opposed to the public
+ * /bookings/by-phone and /bookings/:bookingRef used by the guest lookup page.
+ */
+
+export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
+
+export interface MyBookingSeat {
+ leg: number;
+ passengerName: string;
+ seatNumber: string | null;
+ coachNumber: string | null;
+}
+
+export interface MyBookingItem {
+ id: string;
+ bookingRef: string;
+ status: string;
+ totalMinor: number;
+ currency?: string | null;
+ displayCurrency?: string | null;
+ displayTotalMinor?: number | null;
+ adultCount: number;
+ childCount: number;
+ bookingType: string;
+ returnLegStatus?: string | null;
+ createdAt: string;
+ schedule: {
+ train?: { number?: string; name?: string } | null;
+ originStation: { id?: string; name: string; code?: string; city?: string };
+ destinationStation: { id?: string; name: string; code?: string; city?: string };
+ departureAt: string;
+ arrivalAt?: string | null;
+ };
+ paymentIntent?: { method?: string; status?: string } | null;
+ seatCount: number;
+ seats: MyBookingSeat[];
+ rescheduled: boolean;
+ outboundBoardedAt: string | null;
+ isPackageBooking: boolean;
+ contactPhone: string | null;
+}
+
+export interface MyBookingsResponse {
+ items: MyBookingItem[];
+ meta: { page: number; pageSize: number; total: number; totalPages: number };
+}
+
+const EMPTY_META = { page: 1, pageSize: 20, total: 0, totalPages: 0 };
+
+export async function fetchMyBookings(params: {
+ scope?: BookingScope;
+ page?: number;
+ pageSize?: number;
+ search?: string;
+}): Promise {
+ const query = new URLSearchParams();
+ if (params.scope) query.set('scope', params.scope);
+ if (params.page) query.set('page', String(params.page));
+ if (params.pageSize) query.set('pageSize', String(params.pageSize));
+ if (params.search?.trim()) query.set('search', params.search.trim());
+
+ // apiClient.get already unwraps `response.data?.data || response.data`, but the API
+ // has been seen to return both shapes for list endpoints — mirror the defensive read
+ // the guest lookup page uses for /bookings/by-phone.
+ const resp: any = await apiClient.get(`/bookings/my?${query.toString()}`);
+ return {
+ items: resp?.items ?? resp?.data?.items ?? [],
+ meta: resp?.meta ?? resp?.data?.meta ?? { ...EMPTY_META, pageSize: params.pageSize ?? 20 },
+ };
+}
+
+/**
+ * Badge presentation for `BookingStatus`. The enum has no COMPLETED and no
+ * RESCHEDULED member (schema.prisma) — BOARDED is what "travelled" looks like, and a
+ * rescheduled booking stays CONFIRMED, so that is shown as a separate chip.
+ * Shared with the guest lookup page so the two lists cannot drift.
+ */
+export const STATUS_LABELS: Record = {
+ CONFIRMED: { label: 'Confirmed', className: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' },
+ PENDING_PAYMENT: { label: 'Pending Payment', className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300' },
+ DRAFT: { label: 'Draft', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
+ CANCELLED: { label: 'Cancelled', className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' },
+ BOARDED: { label: 'Completed', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' },
+ NO_SHOW: { label: 'No Show', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
+ REFUNDED: { label: 'Refunded', className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300' },
+};
+
+export function statusBadge(status: string) {
+ return STATUS_LABELS[status] ?? { label: status, className: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' };
+}
diff --git a/apps/edr-passenger-web/portal/src/middleware.ts b/apps/edr-passenger-web/portal/src/middleware.ts
index 9604317f7..7766a1311 100644
--- a/apps/edr-passenger-web/portal/src/middleware.ts
+++ b/apps/edr-passenger-web/portal/src/middleware.ts
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
// Routes that should NOT redirect to home on hard refresh
const PRESERVED_ROUTES = [
'/booking/',
+ // Note the trailing slash above: '/booking/' does not match '/bookings'.
+ '/bookings',
'/login',
'/register',
'/forgot-password',
@@ -117,6 +119,7 @@ export function middleware(request: NextRequest) {
// Tell crawlers not to index private/transactional routes.
const NOINDEX_PREFIXES = [
'/booking/',
+ '/bookings',
'/login',
'/register',
'/forgot-password',