mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
|
||||
arrivalAt: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
@@ -263,9 +265,13 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
||||
* the wagons are allocated before marshalling (import), so it is only available
|
||||
* once the booking has wagon allocations.
|
||||
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||
* Not loaded and the totals count only the loaded ones: the customer sees the
|
||||
* whole plan on one page without the sheet overstating what EDR has taken.
|
||||
*
|
||||
* Handed to the customer when EDR accepts the cargo (export) and when the
|
||||
* wagons are allocated before marshalling (import).
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
@@ -285,6 +291,7 @@ export class BookingsService {
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
a.status AS "status",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
@@ -299,7 +306,7 @@ export class BookingsService {
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
@@ -383,6 +390,8 @@ export class BookingsService {
|
||||
arrivalAt: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
// A received line has no allocation; it is cargo EDR already holds.
|
||||
status: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -497,7 +506,17 @@ export class BookingsService {
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
|
||||
// totals count only those: the sheet shows the whole plan, but must never
|
||||
// total up cargo still sitting in the yard. A received-line sheet
|
||||
// (pendingWagons) has no allocation status, and every line on it is cargo
|
||||
// already accepted, so it counts in full.
|
||||
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
|
||||
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
|
||||
const loadedWagons = wagons.filter(isLoaded);
|
||||
const notLoadedCount = wagons.length - loadedWagons.length;
|
||||
|
||||
const totals = loadedWagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
@@ -507,7 +526,7 @@ export class BookingsService {
|
||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||
);
|
||||
// A wagon carrying no weight and no container is running empty under this booking.
|
||||
const fullWagons = wagons.filter(
|
||||
const fullWagons = loadedWagons.filter(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
@@ -525,6 +544,9 @@ export class BookingsService {
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</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>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -535,11 +557,11 @@ export class BookingsService {
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
@@ -549,6 +571,7 @@ export class BookingsService {
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -575,6 +598,8 @@ export class BookingsService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.loaded { color: #0f766e; font-weight: 700; }
|
||||
.pending { color: #b45309; font-weight: 700; }
|
||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
@@ -618,6 +643,7 @@ export class BookingsService {
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -176,6 +176,18 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract suspension lifted', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice cancelled the contract. Terminal — the customer is told they may
|
||||
* submit a new contract with the same details if they still need the service.
|
||||
*/
|
||||
cancelledByStaff(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
|
||||
`If you still need this service you can submit a new contract request with the same details.`;
|
||||
void this.notifyContact(c, msg, 'CANCELLED');
|
||||
this.inApp(c, 'Contract cancelled', msg);
|
||||
}
|
||||
|
||||
/** Customer cancelled their own contract — staff-side record. */
|
||||
cancelledByCustomer(c: Contract, reason: string): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,15 @@ export class ContractPricingService {
|
||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||
*/
|
||||
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 isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
|
||||
* with the same parameters can be submitted afterwards (references are minted
|
||||
* per contract, so nothing about the old row blocks the new one).
|
||||
*
|
||||
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
|
||||
* contract is exactly the one staff most often need to kill outright.
|
||||
*/
|
||||
async cancelByStaff(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<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> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
RequestChangesDto,
|
||||
ResumeContractDto,
|
||||
SuspendContractDto,
|
||||
CancelContractByStaffDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
@@ -552,6 +553,25 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
|
||||
})
|
||||
cancelByStaff(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelContractByStaffDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.cancelByStaff(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
|
||||
@@ -51,6 +51,14 @@ export class CancelContractDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
|
||||
export class CancelContractByStaffDto {
|
||||
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class SuspendContractDto {
|
||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||
@IsString()
|
||||
|
||||
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
|
||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One container line on the load manifest notice. */
|
||||
interface LoadManifestLists {
|
||||
reference: string;
|
||||
companyId: string | null;
|
||||
trainNumber: string | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
departureAt: Date | null;
|
||||
loaded: string[];
|
||||
leftBehind: string[];
|
||||
}
|
||||
|
||||
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
|
||||
function summarizeNumbers(numbers: string[], max = 5): string {
|
||||
if (numbers.length === 0) return 'none';
|
||||
const shown = numbers.slice(0, max).join(', ');
|
||||
const rest = numbers.length - max;
|
||||
return rest > 0 ? `${shown} +${rest} more` : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what actually went on the train and what did not. Left behind = every
|
||||
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
|
||||
* wagon, so a booking loaded in parts reports honestly on both halves.
|
||||
*/
|
||||
export async function loadManifestLists(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
): Promise<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,12 +508,12 @@ export class RuleEngineService {
|
||||
|
||||
if (input.tradeDirection === 'IMPORT') {
|
||||
appliedModifiers.push(
|
||||
...this.derivedImportOverweight(
|
||||
...(await this.derivedImportOverweight(
|
||||
input,
|
||||
containerWeightResults,
|
||||
lineMaxVgmTons,
|
||||
liveRates,
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -540,23 +540,37 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Import overweight — derived, never configured. Each overweight container
|
||||
* line bills its excess tons at (its own base import freight on the booking's
|
||||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
||||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
||||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
||||
* bookings — the frozen snapshot has no route-scoped container price to
|
||||
* divide.
|
||||
* Import overweight — derived, never configured. Excess tons are billed on a
|
||||
* PER-WAGON basis: (the wagon's base import freight on the booking's route)
|
||||
* ÷ (2 × the container's weight limit).
|
||||
*
|
||||
* The rate is normalised to a wagon before dividing, because a 20ft rate
|
||||
* quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
|
||||
* while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
|
||||
* doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
|
||||
* - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
|
||||
* - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
|
||||
* Halving over 2 × the limit keeps the original meaning: filling one wagon's
|
||||
* worth of excess costs one extra wagon of freight.
|
||||
*
|
||||
* Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE
|
||||
* route rate even for frozen-rate contract bookings — the frozen snapshot has
|
||||
* no route-scoped container price to divide.
|
||||
*/
|
||||
private derivedImportOverweight(
|
||||
private async derivedImportOverweight(
|
||||
input: BookingEvaluationInput,
|
||||
weightResults: ContainerWeightResult[],
|
||||
lineMaxVgmTons: Array<number | null>,
|
||||
liveRates: Rate[],
|
||||
): AppliedCargoModifier[] {
|
||||
): Promise<AppliedCargoModifier[]> {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||
|
||||
// How many of each container type ride one wagon: a 40ft fills a wagon,
|
||||
// two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
|
||||
// be scaled up to the wagon the overweight formula prices against.
|
||||
const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
|
||||
|
||||
for (let i = 0; i < weightResults.length; i++) {
|
||||
const wr = weightResults[i];
|
||||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||
@@ -578,7 +592,16 @@ export class RuleEngineService {
|
||||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||
if (!base) continue;
|
||||
|
||||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
||||
// Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
|
||||
// rate covers half a wagon, so it is scaled by the 2 containers that ride
|
||||
// one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
|
||||
// price already — never scale it again.
|
||||
const perWagonRate =
|
||||
base.rateUnit === 'PER_CONTAINER'
|
||||
? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
|
||||
: Number(base.rateValue);
|
||||
|
||||
const perTon = perWagonRate / (2 * maxVgm);
|
||||
const amount = excess * perTon;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
@@ -595,6 +618,42 @@ export class RuleEngineService {
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers of each type that ride ONE wagon, derived from the type's
|
||||
* size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
|
||||
* caller actually needs are looked up. Unknown or non-positive sizes fall
|
||||
* back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
|
||||
* behaviour, so a missing size can never inflate a bill.
|
||||
*/
|
||||
private async containersPerWagonByTypeId(
|
||||
weightResults: ContainerWeightResult[],
|
||||
): Promise<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
|
||||
* base freight. Each container line that opted in (returnQuantity, or every
|
||||
|
||||
@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
|
||||
import {
|
||||
notifyCarriageAcceptanceReady,
|
||||
notifyLoadManifest,
|
||||
} from '../notifications/notify-company.util';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
@@ -267,6 +271,20 @@ export class BookingJourneyService {
|
||||
});
|
||||
});
|
||||
|
||||
// What actually boarded, and what did not. A booking is routinely loaded in
|
||||
// parts; the customer is told both halves, and the warehouse desk is told
|
||||
// about the leftovers so somebody owns placing them. After the transaction:
|
||||
// the lists are read back from the allocation statuses it just wrote.
|
||||
void notifyLoadManifest(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
this.inbox,
|
||||
bookingId,
|
||||
scheduleId,
|
||||
FREIGHT_PERMS.warehouseInventory.getNotification,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||
// bookings (intercity) and already-completed codes.
|
||||
|
||||
@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
|
||||
it('drops a leg slot entirely from the import document — not part of the departing consist', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
|
||||
// Departure station of the leg slot is its board yard, not the origin.
|
||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||
// Only the origin-loaded container counts; the leg slot's tallies separately.
|
||||
// The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
|
||||
// it isn't on the departing consist. Only W-IMP appears.
|
||||
expect(html).not.toContain('W-ICY');
|
||||
expect(html).not.toContain('ICY-001');
|
||||
expect(html).toContain('W-IMP');
|
||||
expect(html).toContain('<span>Wagons</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 = {
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
|
||||
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
||||
});
|
||||
|
||||
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
|
||||
// The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all.
|
||||
expect(html).not.toContain('W-LEG');
|
||||
expect(html).toContain('<span>Wagons</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', () => {
|
||||
@@ -1240,6 +1242,44 @@ describe('TrainSchedulingService', () => {
|
||||
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
||||
});
|
||||
|
||||
it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => {
|
||||
const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' };
|
||||
const legSlot = {
|
||||
...makeWagon(2, 'W-LEG', [loadedAllocation]),
|
||||
id: 'slot-leg',
|
||||
boardYardId: 'yard-dire',
|
||||
alightYardId: 'yard-adama',
|
||||
};
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
originStation: { label: 'DCT/SGTD' },
|
||||
destinationStation: { label: 'GMP (Gelan Multipurpose Port)' },
|
||||
trainSet: { wagons: [wholeRoute, legSlot] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
const build = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml.bind(service);
|
||||
|
||||
const html = build(schedule, {
|
||||
yardLabelById: new Map([
|
||||
['yard-dire', 'Dire Dawa Port'],
|
||||
['yard-adama', 'Adama'],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(html).toContain('<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', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
@@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => {
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||
// the time this runs — both LOADED right now. Only the adjustment log
|
||||
// knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp.
|
||||
const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||
const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' };
|
||||
const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' };
|
||||
const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] };
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop
|
||||
const wagonsAsOfStop = (service as never as {
|
||||
wagonsAsOfStop: (w: unknown, s: Set<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', () => {
|
||||
const rider = {
|
||||
id: 'booking-9',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThanOrEqual,
|
||||
Not,
|
||||
QueryFailedError,
|
||||
Raw,
|
||||
@@ -3556,17 +3557,19 @@ export class TrainSchedulingService {
|
||||
|
||||
// Leg slots couple mid-corridor — this origin document must say where their
|
||||
// cargo boards instead of listing it as loaded here (see the import list).
|
||||
const slotYardLabels = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
|
||||
// Also doubles as the per-row Departure/Arrival Station lookup below.
|
||||
const yardLabelById = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||
);
|
||||
const pendingBoardYardLabelBySlot = new Map(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => wagon.boardYardId)
|
||||
.map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
|
||||
.map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']),
|
||||
);
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
pendingBoardYardLabelBySlot,
|
||||
yardLabelById,
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
@@ -3623,6 +3626,24 @@ export class TrainSchedulingService {
|
||||
return { wagons, unassignedBookings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects intercityOnBoardView's CURRENT-state wagon list against a
|
||||
* specific stop's document. intercityOnBoardView's "boardYardId == null ||
|
||||
* hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
|
||||
* "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
|
||||
* that has, by generation time, also already happened" (both look LOADED).
|
||||
* Reprinting an earlier stop's document after a later one has run would
|
||||
* otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
|
||||
* set of physical wagon numbers with a logged ADD at or before this stop
|
||||
* (see marshallingDocumentAt) — the ground truth a real-time heuristic
|
||||
* can't provide once multiple stops have already happened.
|
||||
*/
|
||||
private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set<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
|
||||
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
||||
@@ -3717,11 +3738,37 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
// intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
|
||||
// CURRENT state — it can't tell "coupled here" from "coupled at a LATER
|
||||
// stop that has since also happened" (both look LOADED by generation
|
||||
// time once the trip has moved past this stop). Reprinting Marshalling 2
|
||||
// after Marshalling 3's stop already ran would otherwise show Marshalling
|
||||
// 3's coupled wagons too. Correct it against the log: a leg-slot wagon
|
||||
// belongs on THIS stop's document only if it actually has a logged ADD
|
||||
// at or before THIS stop's own timestamp.
|
||||
const boardedByThisStop = new Set(
|
||||
(
|
||||
await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||
where: {
|
||||
trainScheduleId: scheduleId,
|
||||
action: 'ADD',
|
||||
occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
|
||||
},
|
||||
})
|
||||
).map((row) => row.wagonNumber),
|
||||
);
|
||||
const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
|
||||
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
|
||||
order: { occurredAt: 'ASC' },
|
||||
});
|
||||
// Per-row Departure/Arrival Station: a whole-route wagon reads the
|
||||
// schedule's own origin/destination, a leg-slot wagon reads where IT
|
||||
// boards/alights instead.
|
||||
const yardLabelById = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||
);
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
|
||||
positionLabel: `At ${stop.yardLabel}`,
|
||||
@@ -3730,6 +3777,7 @@ export class TrainSchedulingService {
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
|
||||
yardLabelById,
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
|
||||
@@ -3769,6 +3817,9 @@ export class TrainSchedulingService {
|
||||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
const yardLabelById = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||
);
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel,
|
||||
@@ -3776,6 +3827,7 @@ export class TrainSchedulingService {
|
||||
unassignedBookings,
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
yardLabelById,
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||
@@ -3845,6 +3897,10 @@ export class TrainSchedulingService {
|
||||
// Slots that couple to the train downstream (slot id → board yard label).
|
||||
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
||||
pendingBoardYardLabelBySlot?: Map<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 /
|
||||
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
||||
// Origin import/export docs never pass this, so they render no such box.
|
||||
@@ -3866,10 +3922,15 @@ export class TrainSchedulingService {
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
// consist order — the relation comes back unordered. Slots planned to
|
||||
// couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
|
||||
// intercity calls never pass it, their wagons list is already on-board
|
||||
// only) are dropped here, not just tallied around: they are not part of
|
||||
// the departing consist, so they get no row and no count on this document.
|
||||
// Their own coupling shows up on THAT stop's own marshalling document.
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
|
||||
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
|
||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||
// slot recorded when they were loaded.
|
||||
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
||||
@@ -3880,15 +3941,28 @@ export class TrainSchedulingService {
|
||||
empty,
|
||||
]);
|
||||
}
|
||||
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
|
||||
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
|
||||
const rows = wagons
|
||||
.flatMap((wagon) => {
|
||||
// Departure/Arrival Station per row: a leg-slot wagon boards/alights
|
||||
// somewhere other than the schedule's own endpoints; a whole-route
|
||||
// wagon just reads origin/destination.
|
||||
const departureLabel = wagon.boardYardId
|
||||
? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route')
|
||||
: originLabel;
|
||||
const arrivalLabel = wagon.alightYardId
|
||||
? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route')
|
||||
: destinationLabel;
|
||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</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.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 ?? [];
|
||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||
// check this document against the physical train — a wagon with no row
|
||||
@@ -3912,11 +3986,10 @@ export class TrainSchedulingService {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
<td colspan="5">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
@@ -3928,7 +4001,7 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${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(containerNumbers || firstContainer?.containerNumber)}</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.
|
||||
const unassigned = opts?.unassignedBookings ?? [];
|
||||
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
|
||||
.map((booking) => {
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
@@ -3950,7 +4023,7 @@ export class TrainSchedulingService {
|
||||
.join(', ');
|
||||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||
return `<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.company?.name)}</td>
|
||||
<td>${esc(containerNumbers)}</td>
|
||||
@@ -3965,27 +4038,20 @@ export class TrainSchedulingService {
|
||||
(wagon.allocations ?? []).length === 0 &&
|
||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||
).length;
|
||||
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum +
|
||||
(loadsHere(wagon)
|
||||
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
||||
: 0),
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||
// physically on the train, so they count, and are called out on their own tile.
|
||||
// Cargo boarding downstream is not on this train yet — it tallies separately.
|
||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
||||
// physically on the train, so they count, and are called out on their own
|
||||
// tile. Cargo boarding downstream never enters this loop — `wagons` above
|
||||
// already excludes those slots.
|
||||
let count40ft = 0, count20ft = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
(wagon.allocations ?? []).forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
if (!loadsHere(wagon)) {
|
||||
pendingContainers++;
|
||||
return;
|
||||
}
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
@@ -4053,7 +4119,6 @@ export class TrainSchedulingService {
|
||||
<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>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>` : ''}
|
||||
<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>
|
||||
@@ -4099,6 +4164,8 @@ export class TrainSchedulingService {
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Departure Station</th>
|
||||
<th>Arrival Station</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Company</th>
|
||||
<th>Container No</th>
|
||||
@@ -4107,7 +4174,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<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}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -4253,30 +4320,23 @@ export class TrainSchedulingService {
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||
const status = loadList.operation.status;
|
||||
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
|
||||
// physical train this Djibouti-side document is checked against, so it must
|
||||
// stay out of the loaded tallies or the gate count stops matching.
|
||||
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
|
||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = loadList.wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum +
|
||||
(loadsHere(wagon)
|
||||
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
||||
: 0),
|
||||
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
|
||||
// consist this Djibouti-side document is checked against yet, so it gets
|
||||
// no row and no count here at all. Its own coupling shows up on THAT
|
||||
// stop's own marshalling document once it actually happens.
|
||||
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
|
||||
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
|
||||
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
|
||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
||||
loadList.wagons.forEach((wagon) => {
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
wagon.allocations.forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
if (!loadsHere(wagon)) {
|
||||
pendingContainers++;
|
||||
return;
|
||||
}
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
@@ -4284,7 +4344,7 @@ export class TrainSchedulingService {
|
||||
});
|
||||
});
|
||||
|
||||
const allocationRows = loadList.wagons
|
||||
const allocationRows = wagons
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
@@ -4317,7 +4377,7 @@ export class TrainSchedulingService {
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</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>
|
||||
</tr>`;
|
||||
},
|
||||
@@ -4384,13 +4444,12 @@ export class TrainSchedulingService {
|
||||
<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>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>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 20ft</span><strong>${esc(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>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
|
||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
@@ -504,7 +505,7 @@ export class TrainBuilderService {
|
||||
if (locomotiveIds.length < 1) {
|
||||
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const yard = await manager
|
||||
.getRepository(Yard)
|
||||
@@ -523,10 +524,76 @@ export class TrainBuilderService {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||
// Capacity math reads the SET's locomotives, not the train's — push the
|
||||
// new pull weight onto the live runs too, or they keep the old ceiling.
|
||||
return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds);
|
||||
});
|
||||
// Re-derive FULL/reopen once committed — a bigger pull weight can free room
|
||||
// on a schedule that had closed as FULL.
|
||||
await this.reconcileWindowsAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED)
|
||||
* schedule formed from it. The three capacity axes are derived from
|
||||
* `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted
|
||||
* when the set is built and never re-synced — so adding a second locomotive
|
||||
* raised `trains.capacity_tons` but left every existing schedule pulling on
|
||||
* the old single-loco ceiling, still refusing bookings for want of weight.
|
||||
*
|
||||
* Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render
|
||||
* from their frozen snapshot and must not be disturbed (same rule as
|
||||
* syncLiveScheduleAfterConsistChange).
|
||||
*/
|
||||
private async syncLiveSchedulesAfterLocomotiveChange(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
locomotiveIds: string[],
|
||||
): Promise<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
|
||||
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||
|
||||
@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
bookingId?: string | null;
|
||||
};
|
||||
booking: {
|
||||
companyId?: string | null;
|
||||
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
bookingId,
|
||||
},
|
||||
booking,
|
||||
bookingId,
|
||||
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
|
||||
grnNumber,
|
||||
direction: bookingDirection,
|
||||
warehouseId: dto.warehouseId,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
});
|
||||
|
||||
return saved.id;
|
||||
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
|
||||
bookingId?: string | null;
|
||||
}): Promise<void> {
|
||||
const phone = params.phone?.trim();
|
||||
if (!phone) return;
|
||||
|
||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||
const bookingReference = params.bookingReference?.trim();
|
||||
const message =
|
||||
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
|
||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||
`Thank you.`;
|
||||
|
||||
// A booking gives us the company, and with it the customer's inbox and
|
||||
// email — not just whatever phone number the gate clerk typed. Without one
|
||||
// (manual or backlog receive) the typed phone is all there is, so the
|
||||
// original SMS-only path stands.
|
||||
let companyId: string | null = null;
|
||||
if (params.bookingId) {
|
||||
try {
|
||||
const [row]: Array<{ companyId: string | null }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[params.bookingId],
|
||||
);
|
||||
companyId = row?.companyId ?? null;
|
||||
} catch (error) {
|
||||
this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Cargo received — GRN issued',
|
||||
body: message,
|
||||
link: params.bookingId ? `/bookings/${params.bookingId}` : undefined,
|
||||
data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null },
|
||||
});
|
||||
// Sends SMS *and* email to the company's own contacts, so the typed
|
||||
// phone below is skipped to avoid texting the customer twice.
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, companyId, message);
|
||||
return;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const phone = params.phone?.trim();
|
||||
if (!phone) return;
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (error) {
|
||||
|
||||
@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:contracts:suspend",
|
||||
"Suspend / resume a signed contract",
|
||||
),
|
||||
// Terminal kill switch. Unlike suspend this cannot be undone — the customer
|
||||
// re-submits a fresh contract with the same parameters instead.
|
||||
perm(
|
||||
"a3000001-0001-4000-8000-00000000001c",
|
||||
"edr_freight_app:contracts:cancel",
|
||||
"Cancel a contract (terminal)",
|
||||
),
|
||||
];
|
||||
|
||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||
@@ -1909,6 +1916,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:additional_charges:get_notification",
|
||||
"Receive additional charge notifications",
|
||||
),
|
||||
perm(
|
||||
"f3a00001-0001-4000-8000-00000000000a",
|
||||
"edr_freight_app:warehouse_inventory:get_notification",
|
||||
"Receive warehouse desk notifications (containers left behind at loading)",
|
||||
),
|
||||
];
|
||||
|
||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
@@ -2061,6 +2073,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
@@ -2375,6 +2388,12 @@ export const FREIGHT_PERMS = {
|
||||
release: "edr_freight_app:warehouse_inventory:release",
|
||||
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
||||
/**
|
||||
* Notification selector, not a route guard — who gets pinged when cargo is
|
||||
* left behind at loading and needs warehouse space. Assign it to whichever
|
||||
* desk owns that; it grants access to nothing.
|
||||
*/
|
||||
getNotification: "edr_freight_app:warehouse_inventory:get_notification",
|
||||
},
|
||||
interchangeDocuments: {
|
||||
view: "edr_freight_app:interchange_documents:view",
|
||||
@@ -2865,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||
FREIGHT_PERMS.contracts.suspend,
|
||||
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||
// already rejects contracts and cancels bookings.
|
||||
FREIGHT_PERMS.contracts.cancel,
|
||||
FREIGHT_PERMS.contracts.editDocument,
|
||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||
// Marketing follows up with the customer when a reviewer sends profile
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Eye,
|
||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
|
||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
|
||||
const [suspendReason, setSuspendReason] = useState("");
|
||||
const [resumeOpen, setResumeOpen] = useState(false);
|
||||
const [resumeNote, setResumeNote] = useState("");
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||
const cancelModal = (
|
||||
<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
|
||||
// approver whose turn it is may edit — so the server decides, not the client.
|
||||
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
{cancelButton}
|
||||
</Stack>
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
|
||||
You do not have permission to lift a suspension.
|
||||
</Text>
|
||||
)}
|
||||
{cancelButton}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Available at every non-terminal status — the early returns above
|
||||
already cover the statuses where cancelling makes no sense. */}
|
||||
{cancelButton}
|
||||
|
||||
{!canAccept &&
|
||||
!inApproval &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance &&
|
||||
!canSuspend && (
|
||||
!canSuspend &&
|
||||
!mayCancel && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/contracts/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
|
||||
@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||
});
|
||||
|
||||
const cancelByStaff = useMutation({
|
||||
mutationFn: (reason: string) =>
|
||||
contractsService.cancelByStaff(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract cancelled"),
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Failed to cancel contract")),
|
||||
});
|
||||
|
||||
const suspend = useMutation({
|
||||
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
|
||||
updateDocument,
|
||||
requestChanges,
|
||||
reject,
|
||||
cancelByStaff,
|
||||
suspend,
|
||||
resume,
|
||||
approveStep,
|
||||
|
||||
@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
// Blob response: the JSON reason is inside the Blob, so
|
||||
// the sync path would show only "status code 400".
|
||||
toast.error(await extractDownloadErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -288,6 +288,13 @@ export const contractsService = {
|
||||
reject: (id: string, reason: string) =>
|
||||
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}. */
|
||||
suspend: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
||||
|
||||
@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
/**
|
||||
* A `responseType: "blob"` request delivers its JSON error body as a Blob, so
|
||||
* reading `error.message` gives "Request failed with status code 400" instead
|
||||
* of the reason. Read the blob back before falling back.
|
||||
*/
|
||||
async function downloadErrorMessage(error: unknown, fallback: string): Promise<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 type { EmptyContainerReturn } from "@/services/bookings.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
|
||||
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
||||
const [bundleBusy, setBundleBusy] = useState(false);
|
||||
|
||||
// The carriage acceptance sheet is its own document, not warehouse paperwork:
|
||||
// direct truck-to-train cargo never sees a warehouse, and this sheet IS its
|
||||
// handover record. Hiding it inside the warehouse bundle made it unfindable.
|
||||
const [casBusy, setCasBusy] = useState(false);
|
||||
const downloadCarriageAcceptance = async () => {
|
||||
setCasBusy(true);
|
||||
const ref = booking.reference ?? booking.id;
|
||||
try {
|
||||
const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
|
||||
saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
|
||||
);
|
||||
} finally {
|
||||
setCasBusy(false);
|
||||
}
|
||||
};
|
||||
const downloadWarehouseDocuments = async () => {
|
||||
setBundleBusy(true);
|
||||
const ref = booking.reference ?? booking.id;
|
||||
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</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) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingsService, BookingScope } from "./bookings.service";
|
||||
import { GuestBookingService } from "./guest-booking.service";
|
||||
import {
|
||||
CreateBookingDto,
|
||||
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
|
||||
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||
@@ -66,6 +68,13 @@ export class BookingsController {
|
||||
required: false,
|
||||
description: "Filter by booking status",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "scope",
|
||||
required: false,
|
||||
enum: ["upcoming", "past", "cancelled", "all"],
|
||||
description:
|
||||
"Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.",
|
||||
})
|
||||
@ApiQuery({ name: "page", required: false, description: "Page number" })
|
||||
@ApiQuery({
|
||||
name: "pageSize",
|
||||
@@ -80,6 +89,7 @@ export class BookingsController {
|
||||
@Req() req: any,
|
||||
@Query("search") search?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("scope") scope?: BookingScope,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
@@ -88,6 +98,7 @@ export class BookingsController {
|
||||
return this.service.findByIamUserId(iamUserId, {
|
||||
search,
|
||||
status,
|
||||
scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
@@ -67,8 +67,15 @@ interface BookingFilters {
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Portal "My bookings" tabs. Only honoured by findByPassengerId. */
|
||||
scope?: BookingScope;
|
||||
}
|
||||
|
||||
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
|
||||
|
||||
/** Statuses that mean the reservation is off — used by the `cancelled` scope. */
|
||||
const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED'];
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
private readonly logger = new Logger(BookingsService.name);
|
||||
@@ -87,16 +94,34 @@ export class BookingsService {
|
||||
) {}
|
||||
|
||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
|
||||
// An IAM user with no Passenger row is normal, not an error: a freshly registered
|
||||
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
|
||||
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
|
||||
if (!passenger) {
|
||||
const page = filters.page ?? 1;
|
||||
const pageSize = filters.pageSize ?? 20;
|
||||
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||
}
|
||||
return this.findByPassengerId(passenger.id, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's authenticated "My bookings" history (GET /bookings/my).
|
||||
*
|
||||
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
|
||||
* correctly, rather than the client filtering one page at a time. Note it filters on
|
||||
* `schedule.departureAt` — the schedule's own origin departure — while each item's
|
||||
* displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own
|
||||
* boarding stop. They differ by the run time to that stop; that is close enough for a
|
||||
* tab filter and avoids a correlated stopTimes query per row.
|
||||
*/
|
||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
|
||||
const where: any = { passengerId };
|
||||
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||
@@ -104,22 +129,41 @@ export class BookingsService {
|
||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status) {
|
||||
|
||||
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
|
||||
// validation error (a 500) rather than being ignored. Only accept real enum members.
|
||||
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
|
||||
const now = new Date();
|
||||
let orderBy: any = { createdAt: 'desc' };
|
||||
if (scope === 'cancelled') {
|
||||
where.status = { in: CLOSED_BOOKING_STATUSES };
|
||||
} else if (scope === 'upcoming' || scope === 'past') {
|
||||
// Don't clobber an explicit `status` filter — intersect with it.
|
||||
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
|
||||
where.schedule = {
|
||||
...(where.schedule ?? {}),
|
||||
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
|
||||
};
|
||||
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy,
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
paymentIntent: true,
|
||||
seats: { include: { seat: true } },
|
||||
seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
// A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the
|
||||
// portal needs this to show a "Rescheduled" chip alongside the real status.
|
||||
reschedules: { where: { status: 'APPLIED' }, select: { id: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.booking.count({ where }),
|
||||
@@ -150,6 +194,21 @@ export class BookingsService {
|
||||
},
|
||||
paymentIntent: booking.paymentIntent,
|
||||
seatCount: booking.seats.length,
|
||||
// Seat/coach per passenger, so the history table can show a Seat / Coach column
|
||||
// without a round trip to GET /bookings/:ref for every row. `leg` disambiguates
|
||||
// outbound (1) from return (2) on a round trip.
|
||||
seats: booking.seats.map((bs: any) => ({
|
||||
leg: bs.leg ?? 1,
|
||||
passengerName: bs.passengerName,
|
||||
seatNumber: bs.seat?.seatNumber ?? null,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
})),
|
||||
rescheduled: ((booking as any).reschedules?.length ?? 0) > 0,
|
||||
// These three let the portal apply the same coarse reschedule gate the booking
|
||||
// detail page uses, without fetching each booking in full.
|
||||
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||
isPackageBooking: !!(booking as any).packageId,
|
||||
contactPhone: (booking as any).contactPhone ?? null,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { format } from "date-fns";
|
||||
import { toZonedDate } from "@/utils/format";
|
||||
import { STATUS_LABELS } from "@/lib/api/bookings";
|
||||
|
||||
type SearchMode = "pnr" | "phone";
|
||||
|
||||
@@ -30,15 +31,6 @@ interface BookingListItem {
|
||||
seatCount: number;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<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() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<SearchMode>("pnr");
|
||||
|
||||
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal file
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,32 +4,19 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import {
|
||||
User, Settings, Ticket, Calendar, MapPin,
|
||||
Download, Trash2, Lock, Bell, CreditCard,
|
||||
import {
|
||||
User, Settings, Ticket,
|
||||
Download, Trash2, Lock, Bell,
|
||||
MapPinned, Palette, CheckCircle,
|
||||
Eye, Edit, LogOut, X
|
||||
Edit, LogOut, X
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
import MyBookingsTable from '@/components/MyBookingsTable';
|
||||
|
||||
type Tab = 'bookings' | 'profile' | 'settings';
|
||||
|
||||
interface Booking {
|
||||
id: string;
|
||||
pnr: string;
|
||||
status: string;
|
||||
totalMinor: number;
|
||||
createdAt: string;
|
||||
trip?: {
|
||||
trainNumber: string;
|
||||
departureAt: string;
|
||||
origin?: { name: string };
|
||||
destination?: { name: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
|
||||
@@ -82,18 +69,6 @@ export default function ProfilePage() {
|
||||
}
|
||||
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
|
||||
|
||||
const { data: bookings, isLoading: loadingBookings } = useQuery({
|
||||
queryKey: ['user-bookings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient.get('/bookings/my-bookings');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated && activeTab === 'bookings',
|
||||
});
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
|
||||
onSuccess: (response) => {
|
||||
@@ -229,16 +204,6 @@ export default function ProfilePage() {
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles = {
|
||||
CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||
PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
||||
CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||
COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
|
||||
};
|
||||
return styles[status as keyof typeof styles] || styles.PENDING;
|
||||
};
|
||||
|
||||
if (!isInitialized || !user) {
|
||||
return (
|
||||
<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' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
|
||||
|
||||
{loadingBookings ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
|
||||
</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>
|
||||
)}
|
||||
{/* Same component as /bookings, so the two never drift. It replaces a card
|
||||
list that called GET /bookings/my-bookings — a route that does not exist
|
||||
(the real one is GET /bookings/my), whose 404 was swallowed, so this tab
|
||||
always read "No bookings yet". */}
|
||||
<MyBookingsTable />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record<string, string> = {
|
||||
'/booking/confirmation': 'confirmation',
|
||||
};
|
||||
|
||||
const NAV_LINKS = [
|
||||
// "My Bookings" resolves differently by session: a signed-in customer gets their own
|
||||
// account history at /bookings, a guest gets the BRN / phone lookup form. Same label
|
||||
// either way, because it is the same intent.
|
||||
const navLinks = (isAuthenticated: boolean) => [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
||||
{ href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
||||
{ href: '/contact', label: 'Contact', icon: Phone },
|
||||
{ href: '/help', label: 'Help', icon: HelpCircle },
|
||||
];
|
||||
@@ -83,7 +86,7 @@ export default function AppSidebar() {
|
||||
</Link>
|
||||
|
||||
<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);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -28,7 +28,14 @@ export default function BottomTabBar() {
|
||||
|
||||
const tabs = [
|
||||
{ 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: isAuthenticated ? '/profile' : '/login',
|
||||
|
||||
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal file
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal file
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal 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' };
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
// Routes that should NOT redirect to home on hard refresh
|
||||
const PRESERVED_ROUTES = [
|
||||
'/booking/',
|
||||
// Note the trailing slash above: '/booking/' does not match '/bookings'.
|
||||
'/bookings',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
@@ -117,6 +119,7 @@ export function middleware(request: NextRequest) {
|
||||
// Tell crawlers not to index private/transactional routes.
|
||||
const NOINDEX_PREFIXES = [
|
||||
'/booking/',
|
||||
'/bookings',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
|
||||
Reference in New Issue
Block a user