Merge pull request #1462 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-29 23:29:45 +03:00
committed by GitHub
33 changed files with 1670 additions and 225 deletions

View File

@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
arrivalAt: string | null; arrivalAt: string | null;
containerNumbers: string | null; containerNumbers: string | null;
sealNumbers: 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. */ /** 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 * Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when * occupies. A booking is routinely loaded in parts (some containers go, the
* the wagons are allocated before marshalling (import), so it is only available * rest wait for the next train), so each row carries a Status of Loaded or
* once the booking has wagon allocations. * 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 }> { async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId); const booking = await this.findById(bookingId);
@@ -285,6 +291,7 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt", s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt", so.label AS "marshalledAt",
sd.label AS "arrivalAt", sd.label AS "arrivalAt",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a FROM freight.wagon_booking_allocations a
@@ -299,7 +306,7 @@ export class BookingsService {
LEFT JOIN freight.wagon_allocation_container_items ci LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL 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 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 s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`, ORDER BY tsw.sequence_no`,
[bookingId], [bookingId],
@@ -383,6 +390,8 @@ export class BookingsService {
arrivalAt: null, arrivalAt: null,
containerNumbers: row.containerNumbers, containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null, 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 header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); 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) => ({ (acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0), tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +526,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 }, { tare: 0, capacity: 0, load: 0, length: 0 },
); );
// A wagon carrying no weight and no container is running empty under this booking. // 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), (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length; ).length;
@@ -525,6 +544,9 @@ export class BookingsService {
<td>${esc(departureStation)}</td> <td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td> <td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td> <td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td> <td class="num">${money(prices[i])}</td>
</tr>`, </tr>`,
) )
@@ -535,11 +557,11 @@ export class BookingsService {
// figure from the printed sheet. // figure from the printed sheet.
const totalsRow = `<tr class="totals"> const totalsRow = `<tr class="totals">
<td>TOT</td> <td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td> <td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td>${ <td>${
pendingWagons pendingWagons
? 'pending marshalling' ? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}` : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}</td> }</td>
<td class="num">${num(totals.tare, 2)}</td> <td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td> <td class="num">${num(totals.length)}</td>
@@ -549,6 +571,7 @@ export class BookingsService {
<td></td> <td></td>
<td></td> <td></td>
<td></td> <td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td> <td class="num">${money(totalAmount)}</td>
</tr>`; </tr>`;
@@ -575,6 +598,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; } .num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; 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; } .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; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +643,7 @@ export class BookingsService {
<th>Departure Station</th> <th>Departure Station</th>
<th>Container No.</th> <th>Container No.</th>
<th>Seal No.</th> <th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th> <th class="num">Price (${esc(currency)})</th>
</tr> </tr>
</thead> </thead>

View File

@@ -176,6 +176,18 @@ export class ContractNotifierService {
this.inApp(c, 'Contract suspension lifted', msg); 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. */ /** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void { cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff( this.inAppStaff(

View File

@@ -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);
});
});

View File

@@ -85,7 +85,15 @@ export class ContractPricingService {
* commodity rate) — NO totals or quantities (doc §9.1). * commodity rate) — NO totals or quantities (doc §9.1).
*/ */
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> { async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
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 currency = contract.paymentCurrency;
const isEtb = currency === 'ETB'; const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;

View File

@@ -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> = {}): 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<Contract>);
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();
});
});

View File

@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
return updated; 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<Contract> {
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<Contract> { async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId); const source = await this.contractsService.findById(contractId);

View File

@@ -72,6 +72,7 @@ import {
RequestChangesDto, RequestChangesDto,
ResumeContractDto, ResumeContractDto,
SuspendContractDto, SuspendContractDto,
CancelContractByStaffDto,
} from './dto/approve-step.dto'; } from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.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') @Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view) @BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' }) @ApiOperation({ summary: 'Approve one approval step in sequence' })

View File

@@ -51,6 +51,14 @@ export class CancelContractDto {
reason?: string; 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 { export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' }) @ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString() @IsString()

View File

@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); 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<LoadManifestLists | null> {
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<void> {
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}`);
}
}

View File

@@ -508,12 +508,12 @@ export class RuleEngineService {
if (input.tradeDirection === 'IMPORT') { if (input.tradeDirection === 'IMPORT') {
appliedModifiers.push( appliedModifiers.push(
...this.derivedImportOverweight( ...(await this.derivedImportOverweight(
input, input,
containerWeightResults, containerWeightResults,
lineMaxVgmTons, lineMaxVgmTons,
liveRates, liveRates,
), )),
); );
} }
@@ -540,23 +540,37 @@ export class RuleEngineService {
} }
/** /**
* Import overweight — derived, never configured. Each overweight container * Import overweight — derived, never configured. Excess tons are billed on a
* line bills its excess tons at (its own base import freight on the booking's * PER-WAGON basis: (the wagon's base import freight on the booking's route)
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit * ÷ (2 × the container's weight limit).
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. *
* Note: derives from the LIVE route rate even for frozen-rate contract * The rate is normalised to a wagon before dividing, because a 20ft rate
* bookings — the frozen snapshot has no route-scoped container price to * quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
* divide. * 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, input: BookingEvaluationInput,
weightResults: ContainerWeightResult[], weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array<number | null>, lineMaxVgmTons: Array<number | null>,
liveRates: Rate[], liveRates: Rate[],
): AppliedCargoModifier[] { ): Promise<AppliedCargoModifier[]> {
const modifiers: AppliedCargoModifier[] = []; const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers; 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++) { for (let i = 0; i < weightResults.length; i++) {
const wr = weightResults[i]; const wr = weightResults[i];
const excess = Number(wr?.overweightExcessTons ?? 0); 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. // No base rate → the base-freight line hard-blocks this booking anyway.
if (!base) continue; 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; const amount = excess * perTon;
if (!(amount > 0)) continue; if (!(amount > 0)) continue;
@@ -595,6 +618,42 @@ export class RuleEngineService {
return modifiers; 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<Map<string, number>> {
const perWagon = new Map<string, number>();
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 * Empty-container return — sold per direction + route + container type, like
* base freight. Each container line that opted in (returnQuantity, or every * base freight. Each container line that opted in (returnQuantity, or every

View File

@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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. * 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 // Customer tracking: cargo is on the train — loading milestones plus the
// direction's "departed" handoff. Doc-trigger path no-ops non-customs // direction's "departed" handoff. Doc-trigger path no-ops non-customs
// bookings (intercity) and already-completed codes. // bookings (intercity) and already-completed codes.

View File

@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)'); 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 = { const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z', generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1', trainScheduleId: 'schedule-1',
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
buildImportLoadListHtml: (l: unknown) => string; buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList); }).buildImportLoadListHtml(loadList);
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
// Departure station of the leg slot is its board yard, not the origin. // it isn't on the departing consist. Only W-IMP appears.
expect(html).toContain('<td>Dire Dawa Port</td>'); expect(html).not.toContain('W-ICY');
// Only the origin-loaded container counts; the leg slot's tallies separately. expect(html).not.toContain('ICY-001');
expect(html).toContain('W-IMP');
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
expect(html).toContain('<span>Total containers</span><strong>1</strong>'); expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
}); });
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 = { const sizedAllocation = {
...loadedAllocation, ...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), 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('<span>Wagons</span><strong>1</strong>');
expect(html).toContain('<span>Total containers</span><strong>1</strong>'); expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
}); });
it('prints the consist-changes table for this stop, and omits it when there are none', () => { 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'); 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('<th>Departure Station</th>');
expect(html).toContain('<th>Arrival Station</th>');
// Whole-route wagon: schedule's own endpoints.
expect(html).toContain('<td>DCT/SGTD</td>');
expect(html).toContain('<td>GMP (Gelan Multipurpose Port)</td>');
// Leg slot: its own board/alight yard, not the schedule's endpoints.
expect(html).toContain('<td>Dire Dawa Port</td>');
expect(html).toContain('<td>Adama</td>');
});
it('lists loaded empty containers by number and states they are empty', () => { it('lists loaded empty containers by number and states they are empty', () => {
const schedule = { const schedule = {
id: 'schedule-1', id: 'schedule-1',
@@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => {
expect(numbers).toEqual(['W-LEG2']); 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<string>) => 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', () => { it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
const rider = { const rider = {
id: 'booking-9', id: 'booking-9',

View File

@@ -28,6 +28,7 @@ import {
ILike, ILike,
In, In,
IsNull, IsNull,
LessThanOrEqual,
Not, Not,
QueryFailedError, QueryFailedError,
Raw, Raw,
@@ -3556,17 +3557,19 @@ export class TrainSchedulingService {
// Leg slots couple mid-corridor — this origin document must say where their // 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). // cargo boards instead of listing it as loaded here (see the import list).
const slotYardLabels = await this.yardLabelsById( // Also doubles as the per-row Departure/Arrival Station lookup below.
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), const yardLabelById = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
); );
const pendingBoardYardLabelBySlot = new Map( const pendingBoardYardLabelBySlot = new Map(
(schedule.trainSet?.wagons ?? []) (schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.boardYardId) .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, { const html = this.buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot, pendingBoardYardLabelBySlot,
yardLabelById,
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
}); });
@@ -3623,6 +3626,24 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings }; 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<string>): TrainSetWagon[] {
return wagons.filter(
(wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
);
}
/** /**
* Every corridor stop where the consist actually changed for this schedule * Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train * (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({ const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId }, where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' }, 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, { const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`, positionLabel: `At ${stop.yardLabel}`,
@@ -3730,6 +3777,7 @@ export class TrainSchedulingService {
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows), consistChangesAtStop: this.consistChangesAt(schedule, logRows),
yardLabelById,
}); });
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument. // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
@@ -3769,6 +3817,9 @@ export class TrainSchedulingService {
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); 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, { const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)', title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel, positionLabel,
@@ -3776,6 +3827,7 @@ export class TrainSchedulingService {
unassignedBookings, unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
yardLabelById,
}); });
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument. // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); 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). // 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. // Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map<string, string>; pendingBoardYardLabelBySlot?: Map<string, string>;
// 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<string, string>;
// Numbered marshalling docs only (see marshallingDocumentAt / // Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop. // consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box. // 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 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])); 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 // The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered. // consist order — the relation comes back unordered. Slots planned to
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), // 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 // Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded. // slot recorded when they were loaded.
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>(); const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
@@ -3880,15 +3941,28 @@ export class TrainSchedulingService {
empty, empty,
]); ]);
} }
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
const rows = wagons const rows = wagons
.flatMap((wagon) => { .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. // Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td> const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td> <td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td> <td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td> <td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td> <td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`; <td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(departureLabel)}</td>
<td>${esc(arrivalLabel)}</td>`;
const allocations = wagon.allocations ?? []; const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff // 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 // check this document against the physical train — a wagon with no row
@@ -3912,11 +3986,10 @@ export class TrainSchedulingService {
return [ return [
`<tr class="empty"> `<tr class="empty">
${wagonCells} ${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td> <td colspan="5">EMPTY — no cargo allocated</td>
</tr>`, </tr>`,
]; ];
} }
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
return allocations.map((allocation) => { return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; 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(', '); const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr> return `<tr>
${wagonCells} ${wagonCells}
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()}` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td> <td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td> <td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td> <td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td> <td>${esc(chassisNumbers)}</td>
@@ -3941,7 +4014,7 @@ export class TrainSchedulingService {
// they are still physically on the train, so they get rows of their own. // they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? []; const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length const unassignedRows = unassigned.length
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` + ? `<tr class="empty"><td colspan="13">ON BOARD — WAGON NOT RECORDED</td></tr>` +
unassigned unassigned
.map((booking) => { .map((booking) => {
const containerNumbers = (booking.bookingContainers ?? []) const containerNumbers = (booking.bookingContainers ?? [])
@@ -3950,7 +4023,7 @@ export class TrainSchedulingService {
.join(', '); .join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `<tr> return `<tr>
<td colspan="6">${esc(booking.reference)}${esc(leg)}</td> <td colspan="8">${esc(booking.reference)}${esc(leg)}</td>
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td> <td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
<td>${esc(booking.company?.name)}</td> <td>${esc(booking.company?.name)}</td>
<td>${esc(containerNumbers)}</td> <td>${esc(containerNumbers)}</td>
@@ -3965,27 +4038,20 @@ export class TrainSchedulingService {
(wagon.allocations ?? []).length === 0 && (wagon.allocations ?? []).length === 0 &&
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length, !emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
).length; ).length;
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
const totalWeight = wagons.reduce( const totalWeight = wagons.reduce(
(sum, wagon) => (sum, wagon) =>
sum + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
(loadsHere(wagon)
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 0),
0, 0,
); );
// Container count summary (40ft, 20ft) — empties returning to Djibouti are // 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. // physically on the train, so they count, and are called out on their own
// Cargo boarding downstream is not on this train yet — it tallies separately. // tile. Cargo boarding downstream never enters this loop — `wagons` above
let count40ft = 0, count20ft = 0, pendingContainers = 0; // already excludes those slots.
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => { wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => { (wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => { (allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item); const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++; if (size === 40) count40ft++;
else if (size === 20) count20ft++; else if (size === 20) count20ft++;
@@ -4053,7 +4119,6 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div> <div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div> <div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div> <div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''} ${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div> <div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div> <div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
@@ -4099,6 +4164,8 @@ export class TrainSchedulingService {
<th class="num">Equated Length</th> <th class="num">Equated Length</th>
<th class="num">Tare Weight</th> <th class="num">Tare Weight</th>
<th class="num">Load Capacity</th> <th class="num">Load Capacity</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Cargo Type</th> <th>Cargo Type</th>
<th>Company</th> <th>Company</th>
<th>Container No</th> <th>Container No</th>
@@ -4107,7 +4174,7 @@ export class TrainSchedulingService {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'} ${rows || '<tr><td colspan="13">No wagons on this train set.</td></tr>'}
${unassignedRows} ${unassignedRows}
</tbody> </tbody>
</table> </table>
@@ -4253,30 +4320,23 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status; const status = loadList.operation.status;
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the // A leg slot (boardYard set) couples mid-corridor — it is not part of the
// physical train this Djibouti-side document is checked against, so it must // consist this Djibouti-side document is checked against yet, so it gets
// stay out of the loaded tallies or the gate count stops matching. // no row and no count here at all. Its own coupling shows up on THAT
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; // stop's own marshalling document once it actually happens.
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
const totalWeight = loadList.wagons.reduce( const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
(sum, wagon) => const totalWeight = wagons.reduce(
sum + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
(loadsHere(wagon)
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 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 // Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0, pendingContainers = 0; let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => { wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => { wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => { (allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item); const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++; if (size === 40) count40ft++;
else if (size === 20) count20ft++; else if (size === 20) count20ft++;
@@ -4284,7 +4344,7 @@ export class TrainSchedulingService {
}); });
}); });
const allocationRows = loadList.wagons const allocationRows = wagons
.flatMap((wagon) => { .flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td> const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td> <td>${esc(wagon.wagonNumber)}</td>
@@ -4317,7 +4377,7 @@ export class TrainSchedulingService {
<td>${esc(allocation.loadType)}</td> <td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td> <td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td> <td>${esc(sealNumbers || '-')}</td>
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td> <td></td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td> <td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`; </tr>`;
}, },
@@ -4384,13 +4444,12 @@ export class TrainSchedulingService {
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div> <div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div> <div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div> <div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div> <div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div> <div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div> <div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div> <div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div> <div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div> <div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div> <div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div> </div>

View File

@@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
@@ -504,7 +505,7 @@ export class TrainBuilderService {
if (locomotiveIds.length < 1) { if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive'); 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 train = await this.getEditableTrain(manager, id);
const yard = await manager const yard = await manager
.getRepository(Yard) .getRepository(Yard)
@@ -523,10 +524,76 @@ export class TrainBuilderService {
await manager await manager
.getRepository(Train) .getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); .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); 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<PendingWindowCheck[]> {
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<void> {
for (const check of pending) {
await this.reconcileWindowAfterConsistChange(check);
}
}
/** /**
* Edit a built train's display identity: name and fixed import/export run * 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 * numbers. Mirrors the build-time number rules — the pair may not collide

View File

@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
bookingId?: string | null;
}; };
booking: { booking: {
companyId?: string | null; companyId?: string | null;
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
grnNumber, grnNumber,
direction: dto.direction, direction: dto.direction,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
bookingId,
}, },
booking, booking,
bookingId, bookingId,
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
grnNumber, grnNumber,
direction: bookingDirection, direction: bookingDirection,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
bookingId: dto.bookingId ?? null,
}); });
return saved.id; return saved.id;
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
bookingId?: string | null;
}): Promise<void> { }): Promise<void> {
const phone = params.phone?.trim();
if (!phone) return;
const ownerName = params.ownerName?.trim() || 'Customer'; const ownerName = params.ownerName?.trim() || 'Customer';
const bookingReference = params.bookingReference?.trim(); const bookingReference = params.bookingReference?.trim();
const message = const message =
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
(params.direction ? `Direction: ${params.direction}. ` : '') + (params.direction ? `Direction: ${params.direction}. ` : '') +
`Thank you.`; `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 { try {
await this.notifications.directSend('sms', phone, message); await this.notifications.directSend('sms', phone, message);
} catch (error) { } catch (error) {

View File

@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:contracts:suspend", "edr_freight_app:contracts:suspend",
"Suspend / resume a signed contract", "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 // 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", "edr_freight_app:additional_charges:get_notification",
"Receive additional charge notifications", "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[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
@@ -2061,6 +2073,7 @@ export const FREIGHT_PERMS = {
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
suspend: "edr_freight_app:contracts:suspend", suspend: "edr_freight_app:contracts:suspend",
cancel: "edr_freight_app:contracts:cancel",
editDocument: "edr_freight_app:contracts:edit_document", editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
@@ -2375,6 +2388,12 @@ export const FREIGHT_PERMS = {
release: "edr_freight_app:warehouse_inventory:release", release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver", deliver: "edr_freight_app:warehouse_inventory:deliver",
inspect: "edr_freight_app:warehouse_inventory:inspect", 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: { interchangeDocuments: {
view: "edr_freight_app:interchange_documents:view", view: "edr_freight_app:interchange_documents:view",
@@ -2865,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.generateContract,
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
FREIGHT_PERMS.contracts.suspend, 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, FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS, ...BOOKING_DESK_NOTIFICATION_KEYS,
// Marketing follows up with the customer when a reviewer sends profile // Marketing follows up with the customer when a reviewer sends profile

View File

@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { import {
Ban,
Check, Check,
Eye, Eye,
// FilePen, // ponytail: back with the "Edit contract articles" button // 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]); const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
// One key both ways — whoever can freeze a contract can unfreeze it. // One key both ways — whoever can freeze a contract can unfreeze it.
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); 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 [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
const [suspendReason, setSuspendReason] = useState(""); const [suspendReason, setSuspendReason] = useState("");
const [resumeOpen, setResumeOpen] = useState(false); const [resumeOpen, setResumeOpen] = useState(false);
const [resumeNote, setResumeNote] = useState(""); 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 = (
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title="Cancel this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> 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.
</Text>
<Textarea
label="Reason for cancellation"
placeholder="Explain why this contract is being cancelled…"
autosize
minRows={3}
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelOpen(false)}>
Keep contract
</Button>
<Button
color="red"
disabled={!cancelReason.trim()}
loading={mutations.cancelByStaff.isPending}
onClick={() =>
mutations.cancelByStaff.mutate(cancelReason, {
onSuccess: () => {
setCancelOpen(false);
setCancelReason("");
},
})
}
>
Cancel contract
</Button>
</Group>
</Stack>
</Modal>
);
const cancelButton = mayCancel ? (
<Button
fullWidth
variant="light"
color="red"
leftSection={<Ban size={16} />}
onClick={() => setCancelOpen(true)}
>
Cancel contract
</Button>
) : null;
// Whether the document is editable depends on WHO is viewing — only the // 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. // 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") { if (status === "CHANGES_REQUESTED") {
return ( return (
<SectionCard icon={Zap} title="Awaiting customer"> <SectionCard icon={Zap} title="Awaiting customer">
<Text size="sm" c="dimmed"> <Stack gap="sm">
No staff actions until the customer resubmits the contract. <Text size="sm" c="dimmed">
</Text> No staff actions until the customer resubmits the contract.
</Text>
{cancelButton}
</Stack>
{cancelModal}
</SectionCard> </SectionCard>
); );
} }
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
You do not have permission to lift a suspension. You do not have permission to lift a suspension.
</Text> </Text>
)} )}
{cancelButton}
</Stack> </Stack>
<Modal <Modal
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
{cancelModal}
</SectionCard> </SectionCard>
); );
} }
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
</Button> </Button>
)} )}
{/* Available at every non-terminal status — the early returns above
already cover the statuses where cancelling makes no sense. */}
{cancelButton}
{!canAccept && {!canAccept &&
!inApproval && !inApproval &&
!canViewContract && !canViewContract &&
!canReviewClearance && !canReviewClearance &&
!canSuspend && ( !canSuspend &&
!mayCancel && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the No staff actions available for this status. Monitor until the
workflow advances. workflow advances.
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>
{cancelModal}
</SectionCard> </SectionCard>
); );
} }

View File

@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
STAFF_REQUEST_CHANGES: (id: string) => STAFF_REQUEST_CHANGES: (id: string) =>
`/contracts/${id}/staff/request-changes`, `/contracts/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`, STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
SUSPEND: (id: string) => `/contracts/${id}/suspend`, SUSPEND: (id: string) => `/contracts/${id}/suspend`,
RESUME: (id: string) => `/contracts/${id}/resume`, RESUME: (id: string) => `/contracts/${id}/resume`,
APPROVE_STEP: (id: string, stepId: string) => APPROVE_STEP: (id: string, stepId: string) =>

View File

@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")), 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({ const suspend = useMutation({
mutationFn: (reason: string) => contractsService.suspend(contractId, reason), mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract suspended"), onSuccess: (data) => onSuccess(data, "Contract suspended"),
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
updateDocument, updateDocument,
requestChanges, requestChanges,
reject, reject,
cancelByStaff,
suspend, suspend,
resume, resume,
approveStep, approveStep,

View File

@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions", clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend", suspend: "edr_freight_app:contracts:suspend",
cancel: "edr_freight_app:contracts:cancel",
editDocument: "edr_freight_app:contracts:edit_document", editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",

View File

@@ -40,6 +40,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type { KpiItem } from "@/components/page"; import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail"; import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
toast.error( // Blob response: the JSON reason is inside the Blob, so
error instanceof Error // the sync path would show only "status code 400".
? error.message toast.error(await extractDownloadErrorMessage(error));
: "Carriage acceptance sheet is not available yet",
);
} }
}} }}
> >

View File

@@ -288,6 +288,13 @@ export const contractsService = {
reject: (id: string, reason: string) => reject: (id: string, reason: string) =>
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }), postContract<Freight.IContract>(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<Freight.IContract>(C.STAFF_CANCEL(id), { reason }),
/** Freeze a signed contract. Reversible — see {@link resume}. */ /** Freeze a signed contract. Reversible — see {@link resume}. */
suspend: (id: string, reason: string) => suspend: (id: string, reason: string) =>
postContract<Freight.IContract>(C.SUSPEND(id), { reason }), postContract<Freight.IContract>(C.SUSPEND(id), { reason }),

View File

@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast"; 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<string> {
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 { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service"; import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download"; 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. // One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false); 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 () => { const downloadWarehouseDocuments = async () => {
setBundleBusy(true); setBundleBusy(true);
const ref = booking.reference ?? booking.id; const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard> </SectionCard>
)} )}
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
<SectionCard>
<CardTitle>Carriage acceptance sheet</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
The record of the cargo EDR has accepted for carriage, listing each wagon and the
containers on it, and marking which have been loaded.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
variant="light"
loading={casBusy}
onClick={downloadCarriageAcceptance}
>
Download sheet
</Button>
</SectionCard>
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */} {/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard> <SectionCard>
<CardTitle>Warehouse documents</CardTitle> <CardTitle>Warehouse documents</CardTitle>

View File

@@ -22,7 +22,7 @@ import {
ApiBody, ApiBody,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; 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 { GuestBookingService } from "./guest-booking.service";
import { import {
CreateBookingDto, CreateBookingDto,
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service"; import { SeatsService } from "../seats/seats.service";
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
@ApiTags("Booking") @ApiTags("Booking")
@Controller("bookings") @Controller("bookings")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } }) // @Throttle({ strict: { limit: 20, ttl: 60_000 } })
@@ -66,6 +68,13 @@ export class BookingsController {
required: false, required: false,
description: "Filter by booking status", 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: "page", required: false, description: "Page number" })
@ApiQuery({ @ApiQuery({
name: "pageSize", name: "pageSize",
@@ -80,6 +89,7 @@ export class BookingsController {
@Req() req: any, @Req() req: any,
@Query("search") search?: string, @Query("search") search?: string,
@Query("status") status?: string, @Query("status") status?: string,
@Query("scope") scope?: BookingScope,
@Query("page") page?: string, @Query("page") page?: string,
@Query("pageSize") pageSize?: string, @Query("pageSize") pageSize?: string,
) { ) {
@@ -88,6 +98,7 @@ export class BookingsController {
return this.service.findByIamUserId(iamUserId, { return this.service.findByIamUserId(iamUserId, {
search, search,
status, status,
scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
page: page ? parseInt(page) : 1, page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20, pageSize: pageSize ? parseInt(pageSize) : 20,
}); });

View File

@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { PaymentsService } from '../payments/payments.service'; import { PaymentsService } from '../payments/payments.service';
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils'; import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
import { normalizePhoneVariants } from '../../common/utils/phone.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 { JourneyDirection } from '../seats/seats.dto';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@@ -67,8 +67,15 @@ interface BookingFilters {
dateTo?: string; dateTo?: string;
page?: number; page?: number;
pageSize?: 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() @Injectable()
export class BookingsService { export class BookingsService {
private readonly logger = new Logger(BookingsService.name); private readonly logger = new Logger(BookingsService.name);
@@ -87,16 +94,34 @@ export class BookingsService {
) {} ) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { 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); 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 = {}) { 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 skip = (page - 1) * pageSize;
const where: any = { passengerId }; const where: any = { passengerId };
if (search) { if (search) {
where.OR = [ where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } }, { bookingRef: { contains: search, mode: 'insensitive' } },
@@ -104,22 +129,41 @@ export class BookingsService {
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, { 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; 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([ const [items, total] = await Promise.all([
this.prisma.booking.findMany({ this.prisma.booking.findMany({
where, where,
skip, skip,
take: pageSize, take: pageSize,
orderBy: { createdAt: 'desc' }, orderBy,
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true, paymentIntent: true,
seats: { include: { seat: true } }, seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
priceTier: { select: { priceMinor: 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 }), this.prisma.booking.count({ where }),
@@ -150,6 +194,21 @@ export class BookingsService {
}, },
paymentIntent: booking.paymentIntent, paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length, 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: { meta: {

View File

@@ -6,6 +6,7 @@ import { useState } from "react";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { format } from "date-fns"; import { format } from "date-fns";
import { toZonedDate } from "@/utils/format"; import { toZonedDate } from "@/utils/format";
import { STATUS_LABELS } from "@/lib/api/bookings";
type SearchMode = "pnr" | "phone"; type SearchMode = "pnr" | "phone";
@@ -30,15 +31,6 @@ interface BookingListItem {
seatCount: number; seatCount: number;
} }
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
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() { export default function BookingLookupPage() {
const router = useRouter(); const router = useRouter();
const [mode, setMode] = useState<SearchMode>("pnr"); const [mode, setMode] = useState<SearchMode>("pnr");

View File

@@ -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 (
<div className="min-h-[60vh] flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)]" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-wrap items-start justify-between gap-3 mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">My Bookings</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Every trip booked on this account.
</p>
</div>
{/* 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. */}
<Link
href="/booking/lookup"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<Search className="w-4 h-4" />
Look up another booking
</Link>
</div>
<MyBookingsTable />
</div>
);
}

View File

@@ -4,32 +4,19 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/components/ThemeProvider'; import { useTheme } from '@/components/ThemeProvider';
import { import {
User, Settings, Ticket, Calendar, MapPin, User, Settings, Ticket,
Download, Trash2, Lock, Bell, CreditCard, Download, Trash2, Lock, Bell,
MapPinned, Palette, CheckCircle, MapPinned, Palette, CheckCircle,
Eye, Edit, LogOut, X Edit, LogOut, X
} from 'lucide-react'; } from 'lucide-react';
import { apiClient } from '@/lib/api-client'; 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 CustomModal from '@/components/CustomModal';
import MyBookingsTable from '@/components/MyBookingsTable';
type Tab = 'bookings' | 'profile' | 'settings'; 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() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore(); const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
@@ -82,18 +69,6 @@ export default function ProfilePage() {
} }
}, [isInitialized, isAuthenticated, user, router, fetchProfile]); }, [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({ const updateProfileMutation = useMutation({
mutationFn: (data: any) => apiClient.patch('/auth/profile', data), mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
onSuccess: (response) => { onSuccess: (response) => {
@@ -229,16 +204,6 @@ export default function ProfilePage() {
setShowModal(true); 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) { if (!isInitialized || !user) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
@@ -322,71 +287,11 @@ export default function ProfilePage() {
{activeTab === 'bookings' && ( {activeTab === 'bookings' && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2> <h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
{/* Same component as /bookings, so the two never drift. It replaces a card
{loadingBookings ? ( list that called GET /bookings/my-bookings — a route that does not exist
<div className="card text-center py-12"> (the real one is GET /bookings/my), whose 404 was swallowed, so this tab
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div> always read "No bookings yet". */}
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p> <MyBookingsTable />
</div>
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
bookings.map((booking: Booking) => (
<div key={booking.id} className="card hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span className={`badge ${getStatusBadge(booking.status)}`}>
{booking.status}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
PNR: {booking.pnr}
</span>
</div>
<div className="grid md:grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.departureAt
? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' })
: 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
{booking.trip?.origin?.name} {booking.trip?.destination?.name}
</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-gray-100 font-semibold">
ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => router.push(`/booking/confirmation?id=${booking.id}`)}
className="btn-secondary text-sm flex items-center gap-2"
>
<Eye className="w-4 h-4" />
View
</button>
</div>
</div>
</div>
))
) : (
<div className="card text-center py-12">
<Ticket className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">No bookings yet</p>
<button onClick={() => router.push('/booking/search')} className="btn-primary">
Book Your First Trip
</button>
</div>
)}
</div> </div>
)} )}

View File

@@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record<string, string> = {
'/booking/confirmation': 'confirmation', '/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: '/', 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: '/contact', label: 'Contact', icon: Phone },
{ href: '/help', label: 'Help', icon: HelpCircle }, { href: '/help', label: 'Help', icon: HelpCircle },
]; ];
@@ -83,7 +86,7 @@ export default function AppSidebar() {
</Link> </Link>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1"> <nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
{NAV_LINKS.map(({ href, label, icon: Icon }) => { {navLinks(isAuthenticated).map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href); const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
return ( return (
<Link <Link

View File

@@ -28,7 +28,14 @@ export default function BottomTabBar() {
const tabs = [ const tabs = [
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, { href: '/', label: 'Home', icon: Home, match: (p: string) => 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: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
{ {
href: isAuthenticated ? '/profile' : '/login', href: isAuthenticated ? '/profile' : '/login',

View File

@@ -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<BookingScope, string> = {
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<BookingScope>('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 (
<div>
{/* Scope tabs */}
<div className="flex flex-wrap items-center gap-2 mb-5">
{SCOPES.map(({ id, label }) => (
<button
key={id}
onClick={() => 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}
</button>
))}
{isFetching && !isLoading && (
<Loader2 className="w-4 h-4 animate-spin text-gray-400" aria-hidden="true" />
)}
</div>
{isLoading ? (
<div className={`${cardClass} py-16 text-center`}>
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)] mx-auto" />
<p className="text-gray-600 dark:text-gray-400 mt-3 text-sm">Loading your bookings</p>
</div>
) : isError ? (
<div className={`${cardClass} py-16 text-center`}>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-gray-700 dark:text-gray-300 mb-4 text-sm">
We could not load your bookings just now.
</p>
<button
onClick={() => 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"
>
<RefreshCw className="w-4 h-4" />
Try again
</button>
</div>
) : items.length === 0 ? (
<div className={`${cardClass} py-16 text-center`}>
<Ticket className="w-14 h-14 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 mb-4">{EMPTY_COPY[scope]}</p>
<button
onClick={() => 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
</button>
</div>
) : (
<>
{/* Desktop: the tabular view */}
<div className={`hidden lg:block ${cardClass} overflow-x-auto`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th scope="col" className="px-4 py-3 font-semibold">Booking Ref</th>
<th scope="col" className="px-4 py-3 font-semibold">Travel date &amp; time</th>
<th scope="col" className="px-4 py-3 font-semibold">Route</th>
<th scope="col" className="px-4 py-3 font-semibold">Seat / Coach</th>
<th scope="col" className="px-4 py-3 font-semibold">Status</th>
<th scope="col" className="px-4 py-3 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={b.id}
className="border-b border-gray-100 dark:border-gray-700/60 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<td className="px-4 py-3 font-mono font-semibold tracking-wider text-gray-900 dark:text-white whitespace-nowrap">
{b.bookingRef}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatTravelDate(b.schedule.departureAt)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{b.schedule.originStation.name} {b.schedule.destinationStation.name}
{b.bookingType === 'ROUND_TRIP' && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
(round trip)
</span>
)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{outbound ?? <span className="text-gray-400"></span>}
{inbound && (
<span className="block text-xs text-gray-500 dark:text-gray-400">
Return: {inbound}
</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
{badge.label}
</span>
{b.rescheduled && (
<span className="ml-1.5 text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
Rescheduled
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => 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 ? (
<>
<CreditCard className="w-3.5 h-3.5" />
Complete payment
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View ticket
</>
)}
</button>
{!actions.isPendingPayment && (
<button
onClick={() => 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"
>
<Clock className="w-3.5 h-3.5" />
Reschedule
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile: the same rows as cards — the portal's established list pattern */}
<div className="lg:hidden space-y-3">
{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 (
<div key={b.id} className={`${cardClass} p-4`}>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="font-mono font-bold tracking-wider text-gray-900 dark:text-white">
{b.bookingRef}
</span>
<div className="flex flex-wrap justify-end gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
{badge.label}
</span>
{b.rescheduled && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
Rescheduled
</span>
)}
</div>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">
{b.schedule.originStation.name} {b.schedule.destinationStation.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{formatTravelDate(b.schedule.departureAt)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Seat / Coach: {outbound ?? '—'}
{inbound && ` · Return: ${inbound}`}
</p>
<div className="flex flex-wrap gap-2 mt-3">
<button
onClick={() => 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 ? (
<>
<CreditCard className="w-3.5 h-3.5" />
Complete payment
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View ticket
</>
)}
</button>
{!actions.isPendingPayment && (
<button
onClick={() => 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"
>
<Clock className="w-3.5 h-3.5" />
Reschedule
</button>
)}
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 mt-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Page {meta?.page ?? page} of {totalPages} · {meta?.total ?? items.length} booking
{(meta?.total ?? items.length) !== 1 ? 's' : ''}
</p>
<div className="flex gap-2">
<button
onClick={() => 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"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<button
onClick={() => 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
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -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<MyBookingsResponse> {
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<string, { label: string; className: string }> = {
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' };
}

View File

@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
// Routes that should NOT redirect to home on hard refresh // Routes that should NOT redirect to home on hard refresh
const PRESERVED_ROUTES = [ const PRESERVED_ROUTES = [
'/booking/', '/booking/',
// Note the trailing slash above: '/booking/' does not match '/bookings'.
'/bookings',
'/login', '/login',
'/register', '/register',
'/forgot-password', '/forgot-password',
@@ -117,6 +119,7 @@ export function middleware(request: NextRequest) {
// Tell crawlers not to index private/transactional routes. // Tell crawlers not to index private/transactional routes.
const NOINDEX_PREFIXES = [ const NOINDEX_PREFIXES = [
'/booking/', '/booking/',
'/bookings',
'/login', '/login',
'/register', '/register',
'/forgot-password', '/forgot-password',