Merge pull request #1489 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-09-04 09:45:20 +03:00
committed by GitHub
98 changed files with 10267 additions and 427 deletions

View File

@@ -124,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
};
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
// An odd credit always leaves a half-empty wagon, so GL must name who fills
// it — the rebook is refused rather than shipping a half-empty wagon.
await expect(
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.toThrow(/pick a consolidation partner/i);
@@ -143,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
}),
).rejects.toThrow(/already shares a wagon/i);
});
/**
* An EXPIRED partner has no pay window left, so pairing the PAID rebook
* straight onto it strands the shared wagon: neither half can board and
* nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a
* fresh booking that carries its own invoice.
*/
it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => {
const dead = {
id: 'p1',
reference: 'BK-2026-001114',
status: 'EXPIRED',
contractId: 'c1',
consolidationPartnerId: null,
paymentCurrency: 'USD',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
scheduledDate: '2026-09-01',
bookingContainers: [
{
containerSize: '20ft',
quantity: 1,
hazardousQuantity: 0,
reeferQuantity: 0,
containerType: { sizeFt: 20 },
units: [
{
containerNumber: 'PCONT0',
sealNumber: null,
vgmTons: 9,
isHazardous: false,
isReefer: false,
},
],
},
],
};
const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' };
const svc = makeSvc(dead) as Record<string, unknown>;
let createdUnderContract: string | null = null;
let pairedWith: string | null = null;
(svc as { bookingsRepository: Record<string, unknown> }).bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead),
hasSpentCancellationCredit: async () => false,
};
(svc as { contractBooking: unknown }).contractBooking = {
createUnderContract: async (contractId: string) => {
createdUnderContract = contractId;
return { booking: { id: 'p1-clone' } };
},
};
(svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined;
const cloned = await (
svc as unknown as {
cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>;
}
).cloneDeadPartner(dead, '2026-09-01');
// The dead booking is left dead; the clone is what gets paired and paid.
expect(cloned.id).toBe('p1-clone');
expect(cloned.reference).toBe('BK-2026-001116');
expect(createdUnderContract).toBe('c1');
expect(pairedWith).toBeNull();
});
});
/**

View File

@@ -1035,6 +1035,9 @@ export class BookingWagonCancellationService {
let partner: Booking | null = null;
if (oddFt20) {
createDto.skipAutoConsolidation = true;
// An odd credit always leaves a half-empty wagon, so GL names who fills
// it. The candidate list is wide enough (any unpaired, unspent booking on
// the day) that a partner is expected to exist.
if (!dto.partnerBookingId) {
throw new BadRequestException(
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
@@ -1045,6 +1048,15 @@ export class BookingWagonCancellationService {
dto.partnerBookingId,
dto.scheduledDate,
);
// A dead partner cannot be paid where it stands — its cargo moves to a
// fresh booking that can carry its own invoice and pay window.
if (['EXPIRED', 'CANCELLED'].includes(partner.status)) {
partner = await this.cloneDeadPartner(
partner,
dto.scheduledDate,
userId,
);
}
}
const created = await this.contractBooking.createUnderContract(
source.contractId,
@@ -1079,6 +1091,15 @@ export class BookingWagonCancellationService {
);
}
if (partner) {
// Corrections GL made to the partner's own containers while pairing —
// scoped to that booking by the repository, so a stray id cannot touch
// another booking's cargo.
if (dto.partnerUnits?.length) {
await this.bookingsRepository.patchContainerUnitsForBooking(
partner.id,
dto.partnerUnits,
);
}
// Consolidated rebook: never allocate the half-wagon booking alone. It
// rides PAID and the batch engine settles the pair atomically once the
// partner's own invoice is paid.
@@ -1130,6 +1151,13 @@ export class BookingWagonCancellationService {
status: string;
scheduledDate: string | null;
ft20Quantity: number;
units: Array<{
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string | null;
vgmTons: number;
}>;
}>
> {
const row = await this.mustFind(cancellationId);
@@ -1150,6 +1178,18 @@ export class BookingWagonCancellationService {
ft20Quantity: (b.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
// Editable while pairing — GL corrects these on the rebook form.
units: (b.bookingContainers ?? []).flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerSize: line.containerType?.sizeFt
? `${line.containerType.sizeFt}ft`
: '',
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons ?? 0),
})),
),
}));
}
@@ -1168,11 +1208,29 @@ export class BookingWagonCancellationService {
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
// Mirrors findRebookConsolidationCandidates: a partner need not be a live
// committed shipment. One that lost its slot or was called off still has
// cargo to move, and the rebooked wagon is how it moves.
if (
![
'SUBMITTED',
'PENDING_CONSOLIDATION',
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
'EXPIRED',
'CANCELLED',
].includes(partner.status)
) {
throw new BadRequestException(
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
);
}
// A booking whose own credit was already rebooked elsewhere is spent.
if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) {
throw new BadRequestException(
`Booking ${partner.reference} has already been rebooked from its cancellation credit.`,
);
}
if (
partner.originYardId !== source.originYardId ||
partner.destinationYardId !== source.destinationYardId ||
@@ -1200,6 +1258,74 @@ export class BookingWagonCancellationService {
return partner;
}
/**
* A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no
* longer be paid: its pay window is gone and finalizing it issues nothing a
* customer can settle, so pairing the PAID rebook with it strands the shared
* wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook,
* no payment_deadline — neither half could ever board). So the cargo is
* cloned into a fresh booking under the same contract, which finalizes
* normally into its own invoice and pay window; the dead booking stays dead.
*/
private async cloneDeadPartner(
partner: Booking,
scheduledDate: string,
userId?: string,
): Promise<Booking> {
if (!partner.contractId) {
throw new BadRequestException(
`Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`,
);
}
const dto: CreateBookingUnderContractDto = {
scheduledDate,
paymentCurrency: partner.paymentCurrency ?? undefined,
// GL already chose this pairing — the auto-matcher must not re-home the
// clone behind their back (same reasoning as the rebooked side).
skipAutoConsolidation: true,
containers: (partner.bookingContainers ?? []).map((line) => {
const units = line.units ?? [];
return {
containerSize: line.containerSize ?? undefined,
quantity: Number(line.quantity),
units: units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? '',
vgmTons: u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
};
}) as CreateBookingUnderContractDto['containers'],
};
const created = await this.contractBooking.createUnderContract(
partner.contractId,
dto,
{ id: userId ?? partner.createdByUserId ?? undefined },
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
// The dead partner's own contract may have lapsed while it sat expired;
// its cargo is still the cargo GL picked to fill the shared wagon.
{ allowExpiredContract: true },
);
const clone = await this.bookingsRepository.findByIdWithFiles(
created.booking.id,
);
if (!clone) {
throw new NotFoundException(
`Replacement booking for ${partner.reference} could not be loaded.`,
);
}
this.notifyCustomer(
partner,
'Replacement booking created',
`${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`,
clone.id,
);
return clone;
}
/**
* Link the rebooked (already PAID) booking with the GL-picked partner. A
* parked partner is resumed the way pairConsolidation would resume it —

View File

@@ -599,6 +599,34 @@ export class BookingsController {
return this.bookingsService.wagonAllocations(id);
}
@Get(":id/wagons/export")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)",
})
async wagonAllocationsExport(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const { filename, buffer } =
await this.bookingsService.wagonAllocationsWorkbook(id);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.

View File

@@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
import { ExportsModule } from '../exports/exports.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
@@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
// CustomersModule,
RuleEngineModule,
FileUploadSettingsModule,
ExportsModule,
SignaturesModule,
registerExchangeModule(),
],

View File

@@ -34,10 +34,12 @@ import {
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
@@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
* Bookings a GL operator may manually link to `booking` as its odd-20ft
* consolidation partner (Path B customs flow). Unlike
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, so
* the filter is deliberately looser: any other customs booking on the same
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
* quantity complement — this lists CANDIDATES for a human to choose from, but
* every row must still be a legal pick: another customs booking on the same
* route/direction, riding the same booking day, that is itself carrying an odd
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
* wagon.
*
* Bare instances awaiting completion have no persisted containers yet, so the
* odd-count test runs on the requested container lines when they exist and the
* booking is offered as a candidate when they do not (GL enters its cargo on
* the split form).
* A booking whose cargo is not entered yet is NOT a candidate: with no
* container lines its 20ft count is unknown, so pairing with it cannot be
* shown to fill the wagon. Same rule as
* {@link findRebookConsolidationCandidates}.
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
@@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
'OPERATION_CHANGES_REQUESTED',
'PENDING_CONSOLIDATION',
],
})
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
});
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
// them on the split form) and stays a candidate; one that already carries
// cargo qualifies only when its 20ft total is odd.
// Same EAT booking day — the pair shares one physical wagon, so it must
// board one train. Applied only when this booking has a date of its own;
// without one there is no day to match against and route/direction stand
// alone, mirroring findComplementaryConsolidationPartner.
if (booking.scheduledDate) {
qb.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: booking.scheduledDate },
);
}
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
// Odd-20ft test in memory. A booking with no container lines has an unknown
// 20ft count, so it cannot be shown to complete the wagon and is not
// offered.
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
if (lines.length === 0) return false;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
@@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
/**
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
* odd-20ft bookings on the same route/direction riding the requested day
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
* odd-20ft bookings on the same route/direction riding the requested day.
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
* GL picks who shares the rebooked wagon whatever the contract kind.
*
* The status set is deliberately wide. A partner here is not required to be a
* live, committed shipment — a booking that lost its slot (EXPIRED) or was
* cancelled still has cargo that GL can put back on a train, and pairing it
* with the rebooked credit is how both halves get moving again. What it must
* not be is already spoken for: a booking whose own cancellation credit has
* been rebooked elsewhere is excluded, as is one already paired.
*/
async findRebookConsolidationCandidates(
booking: Booking,
@@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
// Units come back so GL can correct the partner's container numbers,
// seals and VGMs while pairing.
.leftJoinAndSelect('bc.units', 'unit')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.consolidationPartnerId IS NULL')
@@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
tradeDirection: booking.tradeDirection,
})
.andWhere('b.status IN (:...statuses)', {
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
statuses: [
'SUBMITTED',
'PENDING_CONSOLIDATION',
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
// Lost its slot or was called off — its cargo is still real and can
// ride the rebooked wagon.
'EXPIRED',
'CANCELLED',
],
})
// A cancelled booking whose own credit was already spent on a rebook is
// gone — pairing with it would hand the same cargo out twice.
.andWhere(
`NOT EXISTS (
SELECT 1 FROM freight.booking_wagon_cancellations c
WHERE c.booking_id = b.id
AND c.rebooked_booking_id IS NOT NULL
AND c.deleted_at IS NULL
)`,
)
// Same EAT booking day as the rebook — the pair shares one physical
// wagon, so it must board one train.
.andWhere(
@@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository<Booking> {
return new Set(rows.map((r) => r.bookingId));
}
/**
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
* something, and it has not been spent on a rebook yet. Surfaced on the GL
* clearance queue so a paid-for credit is visibly rebookable from the list
* rather than only from the booking's own page.
*/
async findBookingsWithRedeemableCredit(
bookingIds: string[],
): Promise<Map<string, string>> {
if (bookingIds.length === 0) return new Map();
const rows = (await this.dataSource
.getRepository(BookingWagonCancellation)
.createQueryBuilder('c')
.select('c.booking_id', 'bookingId')
.addSelect('c.id', 'cancellationId')
.where('c.booking_id IN (:...bookingIds)', { bookingIds })
.andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' })
.andWhere('c.credit_amount > 0')
.andWhere('c.rebooked_booking_id IS NULL')
.andWhere('c.deleted_at IS NULL')
.getRawMany()) as Array<{ bookingId: string; cancellationId: string }>;
return new Map(rows.map((r) => [r.bookingId, r.cancellationId]));
}
/**
* Apply container-unit corrections (number / seal / VGM) to units that belong
* to `bookingId`. The ownership join is the point: a unit id from another
* booking silently matches nothing rather than editing a stranger's cargo.
* Sizes and quantities are never touched — only the identifying details.
* Returns how many units were actually updated.
*/
async patchContainerUnitsForBooking(
bookingId: string,
patches: Array<{
id: string;
containerNumber?: string;
sealNumber?: string;
vgmTons?: number;
}>,
): Promise<number> {
if (patches.length === 0) return 0;
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const owned = await unitRepo
.createQueryBuilder('u')
.innerJoin('u.bookingContainer', 'bc')
.where('bc.booking_id = :bookingId', { bookingId })
.andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) })
.select('u.id', 'id')
.getRawMany<{ id: string }>();
const ownedIds = new Set(owned.map((r) => r.id));
let updated = 0;
for (const patch of patches) {
if (!ownedIds.has(patch.id)) continue;
const set: Record<string, unknown> = {};
if (patch.containerNumber !== undefined)
set.containerNumber = patch.containerNumber;
if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber;
if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons;
if (Object.keys(set).length === 0) continue;
await unitRepo.update(patch.id, set as never);
updated += 1;
}
return updated;
}
/**
* Has this booking's own wagon-cancellation credit already been spent on a
* rebook? Such a booking must not be offered or accepted as a consolidation
* partner — its cargo has already moved to the rebooked booking.
*/
async hasSpentCancellationCredit(bookingId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(BookingWagonCancellation)
.createQueryBuilder('c')
.where('c.booking_id = :bookingId', { bookingId })
.andWhere('c.rebooked_booking_id IS NOT NULL')
.andWhere('c.deleted_at IS NULL')
.getCount();
return count > 0;
}
findDocumentReview(
bookingId: string,
settingCode: string,
@@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
select: { bookingId: true, trainScheduleId: true },
});
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
// The allocated train's own departure date — distinct from the customer's
// requested `booking.scheduledDate`. The list column shows this once a
// booking is on a train, so fetch it alongside the link ids.
const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[];
const schedules = scheduleIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
where: { id: In(scheduleIds) },
select: {
id: true,
reference: true,
trainNumber: true,
status: true,
scheduledDepartureDate: true,
},
})
: [];
const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule]));
for (const item of items) {
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
scheduleByBooking.get(item.id) ?? null;
const scheduleId = scheduleByBooking.get(item.id) ?? null;
const enriched = item as Booking & {
trainScheduleId?: string | null;
trainScheduleReference?: string | null;
trainScheduleDepartureDate?: string | null;
};
enriched.trainScheduleId = scheduleId;
const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined;
enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null;
enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate).toISOString()
: null;
}
}

View File

@@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TabularExportService } from '../exports/tabular-export.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -104,6 +105,38 @@ export interface PaginatedBookings {
};
}
/** One container on an allocated wagon (raw SQL json_agg projection). */
export interface WagonAllocationContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: number | null;
sizeFt: number | null;
}
/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */
export interface WagonAllocationRow {
allocationId: string;
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
/** numeric columns arrive as strings from pg. */
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: string | null;
status: string | null;
trainNumber: string | null;
departureAt: string | Date | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: WagonAllocationContainer[];
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
@@ -172,6 +205,7 @@ export class BookingsService {
private readonly bookingContractService: BookingContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly tabularExport: TabularExportService,
) {}
async assignCustomerTruck(
@@ -447,7 +481,7 @@ export class BookingsService {
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
async wagonAllocations(bookingId: string): Promise<WagonAllocationRow[]> {
return this.dataSource.query(
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
@@ -501,6 +535,103 @@ export class BookingsService {
);
}
/**
* The Wagons tab's Excel export: the booking's customer identity in the KPI
* header, then one row per allocated wagon.
*
* Container numbers are flattened into a single cell rather than exploded
* into one row per container — the sheet is a wagon manifest, and a reader
* counting rows must get the wagon count.
*/
async wagonAllocationsWorkbook(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
const wagons = await this.wagonAllocations(bookingId);
// Same precedence the booking list uses: a shipping line owns its bookings
// directly, a government booking names its institution, everyone else is
// the customer company.
// `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies),
// not a declared relation on the entity — hence the cast, matching that helper.
const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } })
.shippingLineCompany;
const customerName =
shippingLine?.name ??
(booking.isGovernment ? booking.governmentInstitution : null) ??
booking.company?.name ??
'—';
const rows = wagons.map((w) => ({
sequenceNo: w.sequenceNo,
wagonNumber: w.wagonNumber ?? '—',
wagonType: w.wagonType ?? '—',
loadType: w.loadType ?? '—',
status: w.status ?? '—',
tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons),
capacityTons: w.capacityTons === null ? null : Number(w.capacityTons),
allocatedWeightTons:
w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons),
lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters),
containerCount: w.containers?.length ?? 0,
containerNumbers:
(w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—',
sealNumbers:
(w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—',
bulkCargo: w.bulkCargoDescription ?? '—',
bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity),
trainNumber: w.trainNumber ?? '—',
departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—',
originStation: w.originStation ?? '—',
destinationStation: w.destinationStation ?? '—',
// Repeated on every row so the sheet survives being filtered, sorted or
// pasted into a combined workbook, where the header block is lost.
customerName,
bookingReference: booking.reference,
}));
const totalAllocated = rows.reduce(
(sum, r) => sum + (r.allocatedWeightTons ?? 0),
0,
);
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${booking.reference}`.slice(0, 31),
description: `Wagons allocated to booking ${booking.reference}${customerName}`,
label: 'booking:wagon-allocations',
kpis: [
{ label: 'Wagons', value: rows.length },
{ label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) },
{ label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'bookingReference', label: 'Booking', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
{ key: 'sequenceNo', label: 'Seq', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon number', type: 'string' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'loadType', label: 'Load type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string' },
{ key: 'tareWeightTons', label: 'Tare', type: 'tons' },
{ key: 'capacityTons', label: 'Capacity', type: 'tons' },
{ key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' },
{ key: 'lengthMeters', label: 'Length (m)', type: 'number' },
{ key: 'containerCount', label: 'Containers', type: 'number' },
{ key: 'containerNumbers', label: 'Container numbers', type: 'string' },
{ key: 'sealNumbers', label: 'Seal numbers', type: 'string' },
{ key: 'bulkCargo', label: 'Bulk cargo', type: 'string' },
{ key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' },
{ key: 'trainNumber', label: 'Train', type: 'string' },
{ key: 'departureAt', label: 'Departure', type: 'date' },
{ key: 'originStation', label: 'Origin', type: 'string' },
{ key: 'destinationStation', label: 'Destination', type: 'string' },
],
rows,
});
return { filename: `wagons-${booking.reference}.xlsx`, buffer };
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding

View File

@@ -105,6 +105,31 @@ export class RebookContainerLineDto {
units!: RebookUnitDto[];
}
/** One edited container unit on the consolidation partner booking. */
export class PartnerUnitPatchDto {
@ApiProperty({ description: 'Id of the partner booking container unit being edited' })
@IsUUID()
id!: string;
@ApiPropertyOptional({ description: 'Container number' })
@IsOptional()
@IsString()
@MaxLength(64)
containerNumber?: string;
@ApiPropertyOptional({ description: 'Seal number' })
@IsOptional()
@IsString()
@MaxLength(64)
sealNumber?: string;
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
@IsOptional()
@IsNumber()
@Min(0)
vgmTons?: number;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
@@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto {
@IsOptional()
@IsUUID()
partnerBookingId?: string;
@ApiPropertyOptional({
description:
'Corrections to the partner booking\'s own container units (number / seal ' +
'/ VGM). Only the units listed are touched; sizes and quantities are never ' +
'changed. Ignored unless partnerBookingId is set.',
type: [PartnerUnitPatchDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PartnerUnitPatchDto)
partnerUnits?: PartnerUnitPatchDto[];
}
export class FilterWagonCancellationsDto {

View File

@@ -43,6 +43,9 @@ function makeService(overrides?: {
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
findBookingsWithRedeemableCredit: jest
.fn()
.mockResolvedValue(new Map<string, string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),

View File

@@ -1347,6 +1347,17 @@ export class BookingClearanceService {
.hasDocumentsAwaitingReview = pending.has(b.id);
}
// A cancelled booking may still hold a paid-for wagon-cancellation credit.
// GL redeems it from this queue, so the row carries the cancellation id the
// rebook action needs.
const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { rebookableCancellationId?: string | null })
.rebookableCancellationId = credits.get(b.id) ?? null;
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}

View File

@@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
// shown to fill the wagon and is not offered.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
@@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
if (lines.length === 0) return false;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
@@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
expect(candidates[0].hasCargo).toBe(true);
});
});

View File

@@ -378,6 +378,13 @@ export class LastMileRequestsService {
contractGeneratedAt: new Date(),
} as Partial<LastMileRequest>);
// Only now, with the request APPROVED, is an advance actually owed on the
// leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already
// opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING —
// otherwise this booking would be dispatchable before the customer has
// signed the contract or paid a birr. No-op for a leg this call just created.
await this.lastMileService.holdForAdvance(lastMile.id);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },

View File

@@ -0,0 +1,214 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service';
import type { LastMileStatus } from './entities/last-mile.entity';
import type { UpdateLastMileDto } from './dto/update-last-mile.dto';
/**
* The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves
* (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery
* chief approved.
*
* It used to leak both ways. The warehouse auto-accept (IMPORT inspection
* PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the
* review, so whichever side acted second found the other already done: accept
* first and the leg was dispatchable before an advance was ever asked for;
* approve first and create() handed the existing dispatchable leg straight back
* untouched.
*/
function makeService(
opts: {
/** APPROVED requests on the booking carrying a positive advance. */
advancesDue?: number;
/** PAID LAST_MILE_ADVANCE invoices on the leg. */
advancesPaid?: number;
status?: LastMileStatus;
} = {},
) {
const leg = {
id: 'lm-1',
bookingId: 'b-1',
status: opts.status ?? 'READY_TO_TRANSIT',
vehicleId: 'v-1',
booking: { reference: 'BK-001' },
};
const query = jest.fn((sql: string) => {
if (sql.includes('customer_truck_assignments')) return Promise.resolve([]);
// The batched list enrichment, not the gate's own lookup.
if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]);
if (sql.includes('freight.last_mile_requests')) {
return Promise.resolve([{ count: opts.advancesDue ?? 0 }]);
}
// Discriminated on the charge type, not the table: attachMileFinancials
// also queries freight.invoices (for the booking-invoice advance line).
if (sql.includes('LAST_MILE_ADVANCE')) {
return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]);
}
if (sql.includes('FROM freight.bookings')) {
return Promise.resolve([
{ tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' },
]);
}
return Promise.resolve([]);
});
const lastMileRepository = {
findAll: jest.fn().mockResolvedValue([]),
findById: jest.fn().mockResolvedValue(leg),
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })),
};
const service = new LastMileService(
lastMileRepository as never,
{} as never, // bookingsRepository
{
findById: jest.fn().mockResolvedValue({
id: 'v-1',
plateNumber: 'AA-123',
assignedDriverId: 'd-1',
assignedDriverName: 'Driver',
}),
setAvailability: jest.fn(),
releaseIfUnused: jest.fn(),
} as never, // vehiclesService
{} as never, // driversService
{} as never, // smsClient
{
query,
// DELIVERED frees the trucks this leg was holding.
manager: {
find: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
} as unknown as DataSource,
{ record: jest.fn() } as never, // history
{ findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
{} as never, // filesService
);
return { service, lastMileRepository, leg };
}
const createdStatus = (repo: { create: jest.Mock }) =>
(repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status;
describe('LastMileService - advance gate on creation', () => {
it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => {
const { service, lastMileRepository } = makeService({ advancesDue: 1 });
// The warehouse path asks for no status at all - it used to get
// READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery.
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING');
});
it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => {
const { service, lastMileRepository } = makeService({ advancesDue: 0 });
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT');
});
});
describe('LastMileService - advance gate on transitions', () => {
it('refuses IN_TRANSIT while the advance is unpaid', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 0 });
await expect(
service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto),
).rejects.toThrow(ADVANCE_UNPAID_MESSAGE);
});
it('refuses a leg being made dispatchable while the advance is unpaid', async () => {
const { service } = makeService({
advancesDue: 1,
advancesPaid: 0,
status: 'PAYMENT_PENDING',
});
await expect(
service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto),
).rejects.toBeInstanceOf(BadRequestException);
});
it('allows IN_TRANSIT once the advance invoice is paid', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 1 });
const updated = await service.update('lm-1', {
status: 'IN_TRANSIT',
} as UpdateLastMileDto);
expect(updated.status).toBe('IN_TRANSIT');
});
it('requires one paid advance per approved departure', async () => {
// Containers arriving across two departures get a request - and an advance
// - each. One paid advance does not release the second.
const { service } = makeService({ advancesDue: 2, advancesPaid: 1 });
await expect(
service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto),
).rejects.toBeInstanceOf(BadRequestException);
});
it('lets the paid listener through before the invoice row is visible', async () => {
// Billing emits inline, pre-commit, when the transition joins a caller's
// transaction - so the invoice still reads unpaid here. The event is the
// proof of payment; re-reading the row would refuse the transition the
// payment just earned.
const { service } = makeService({
advancesDue: 1,
advancesPaid: 0,
status: 'PAYMENT_PENDING',
});
const updated = await service.update(
'lm-1',
{ status: 'READY_TO_TRANSIT' } as UpdateLastMileDto,
{ advanceSettled: true },
);
expect(updated.status).toBe('READY_TO_TRANSIT');
});
it('leaves states that are not transit alone', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 0 });
await expect(
service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto),
).resolves.toBeDefined();
});
});
describe('LastMileService.holdForAdvance', () => {
it('pulls an already-dispatchable leg back when approval imposes an advance', async () => {
const { service, lastMileRepository } = makeService({
advancesDue: 1,
status: 'READY_TO_TRANSIT',
});
await service.holdForAdvance('lm-1');
expect(lastMileRepository.update).toHaveBeenCalledWith(
'lm-1',
expect.objectContaining({ status: 'PAYMENT_PENDING' }),
);
});
it('never rewrites a leg that is already on the road', async () => {
const { service, lastMileRepository } = makeService({
advancesDue: 1,
status: 'IN_TRANSIT',
});
await service.holdForAdvance('lm-1');
expect(lastMileRepository.update).not.toHaveBeenCalled();
});
});

View File

@@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
'createdAt',
];
/** The states that mean the delivery is dispatchable or already on the road. */
const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT'];
export const ADVANCE_UNPAID_MESSAGE =
'The last-mile advance has not been paid yet — this delivery cannot become ' +
'dispatchable or move until the advance invoice is settled.';
@Injectable()
export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
@@ -93,9 +100,47 @@ export class LastMileService {
for (const r of records) {
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
await this.attachAdvanceState(records);
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
}
/**
* Flag the legs whose advance is still owed, so the UI can disable the actions
* the API would refuse instead of firing them into a 400. Same rule as
* {@link advanceOutstanding}, batched over the whole page.
*/
private async attachAdvanceState(records: LastMile[]): Promise<void> {
const ids = records.map((r) => r.id).filter(Boolean);
if (!ids.length) return;
const rows: Array<{ lastMileId: string; due: number; paid: number }> =
await this.dataSource.query(
`SELECT lm.id AS "lastMileId",
(SELECT COUNT(*)
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = lm.booking_id
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED'
AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due",
(SELECT COUNT(*)
FROM freight.invoices i
WHERE i.source = 'last_mile'
AND i.source_id = lm.id::text
AND i.type = 'LAST_MILE_ADVANCE'
AND i.status = 'PAID'
AND i.deleted_at IS NULL)::int AS "paid"
FROM freight.last_mile lm
WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`,
[ids],
);
const outstanding = new Map(
rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]),
);
for (const r of records) {
(r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding =
outstanding.get(r.id) ?? false;
}
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
@@ -185,6 +230,67 @@ export class LastMileService {
}
}
/**
* Whether this booking still owes an advance on its delivery.
*
* An advance is owed for every APPROVED last-mile request carrying a positive
* approved amount — a booking whose containers arrive across several
* departures gets a request, and therefore an advance, per departure. Each is
* settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the
* customer signs that request's contract, so the leg is clear only once it has
* as many paid advance invoices as the booking has approved requests.
*
* A booking with no approved request owes nothing and is unaffected: legs that
* never went through the confirmation flow keep behaving exactly as before.
* `lastMileId` is null while the leg is still being created — no invoice can
* point at a row that does not exist yet, so nothing can have been settled.
*/
private async advanceOutstanding(
bookingId: string,
lastMileId: string | null,
): Promise<boolean> {
const [due] = await this.dataSource.query(
`SELECT COUNT(*)::int AS "count"
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = $1
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED'
AND COALESCE(lmr.approved_advance_amount, 0) > 0`,
[bookingId],
);
const owed = Number(due?.count ?? 0);
if (!owed) return false;
if (!lastMileId) return true;
const [paid] = await this.dataSource.query(
`SELECT COUNT(*)::int AS "count"
FROM freight.invoices i
WHERE i.source = 'last_mile'
AND i.source_id = $1
AND i.type = 'LAST_MILE_ADVANCE'
AND i.status = 'PAID'
AND i.deleted_at IS NULL`,
[lastMileId],
);
return Number(paid?.count ?? 0) < owed;
}
/**
* Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it.
*
* The warehouse auto-accept (IMPORT inspection PASSED) opens the leg
* independently of the chief's review, and opens it at READY_TO_TRANSIT. When
* that happens first, approval has to pull the leg back — otherwise the advance
* gate never holds on that ordering and the delivery is dispatchable unpaid.
* A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what
* happened, not a plan that can still be changed.
*/
async holdForAdvance(id: string): Promise<void> {
const record = await this.findById(id);
if (record.status !== 'READY_TO_TRANSIT') return;
await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto);
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -425,9 +531,16 @@ export class LastMileService {
await this.assertEdrHaulsThisBooking(dto.bookingId);
// A leg that owes an advance is not dispatchable, whatever the caller asked
// for. The warehouse auto-accept path asks for no status at all and used to
// land straight in READY_TO_TRANSIT, which let an unpaid delivery go.
const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null))
? 'PAYMENT_PENDING'
: (dto.status ?? 'READY_TO_TRANSIT');
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
status,
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')),
@@ -461,11 +574,16 @@ export class LastMileService {
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
if (payload.type === 'LAST_MILE_ADVANCE') {
// Advance paid → the leg becomes dispatchable, not delivered.
await this.update(payload.sourceId, {
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto);
// Advance paid → the leg becomes dispatchable, not delivered. This event
// IS the settlement, so it carries its own way past the advance gate.
await this.update(
payload.sourceId,
{
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto,
{ advanceSettled: true },
);
this.logger.log(
`Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`,
);
@@ -484,9 +602,32 @@ export class LastMileService {
}
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
/**
* `opts.advanceSettled` is the paid listener's own bypass, and nothing else
* should pass it: the invoice event is itself the proof of payment, and it can
* reach us inline before the invoice row commits (billing emits before commit
* when the transition is enlisted in a caller-supplied manager), so re-reading
* the invoice here would still see it unpaid and refuse the very transition the
* payment just earned.
*/
async update(
id: string,
dto: UpdateLastMileDto,
opts: { advanceSettled?: boolean } = {},
): Promise<LastMile> {
const existing = await this.findById(id);
// Nothing becomes dispatchable, and nothing moves, until the advance is paid.
if (
!opts.advanceSettled &&
dto.status !== undefined &&
dto.status !== existing.status &&
TRANSIT_STATUSES.includes(dto.status) &&
(await this.advanceOutstanding(existing.bookingId, id))
) {
throw new BadRequestException(ADVANCE_UNPAID_MESSAGE);
}
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {

View File

@@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => {
rate?: Rate;
pending?: RateChangeRequest | null;
applyThrows?: Error;
/** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */
derived?: Partial<Rate>;
previewThrows?: Error;
} = {}) => {
const rate = opts.rate ?? liveRate();
const saved: RateChangeRequest[] = [];
@@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => {
const rates = {
findById: jest.fn(async () => rate),
assertUpdateValid: jest.fn(async () => undefined),
// Stands in for buildUpdate: it resolves a patch into the full column
// set, including columns the form never posts (rateType and friends).
previewUpdate: jest.fn(async (_id: string, dto: Record<string, unknown>) => {
if (opts.previewThrows) throw opts.previewThrows;
return { ...dto, ...(opts.derived ?? {}) } as Partial<Rate>;
}),
applyApprovedUpdate: jest.fn(async () => {
if (opts.applyThrows) throw opts.applyThrows;
return rate;
@@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => {
});
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
const { service, rates } = build();
rates.assertUpdateValid.mockRejectedValueOnce(
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
);
// Resolving the patch IS the validation — buildUpdate throws on a bad
// unit, so previewUpdate surfaces it at submit time.
const { service } = build({
previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
});
await expect(
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
).rejects.toThrow(/not valid for this rate/);
});
it('shows the approver a bulk switch, which only exists as a derived column', async () => {
// The form posts intercityKind: BULK — never stored. The real edit lands
// on rateType (+ the cargo/container swap), so that is what the approver
// must see. Diffing the raw patch showed an empty change list.
const { service } = build({
rate: liveRate({
rateType: 'INTERCITY_CONTAINER',
appliesTo: 'INTERCITY',
containerTypeId: 'ct-1',
}),
derived: {
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
} as Partial<Rate>,
});
const request = await service.submit({
rateId: 'rate-1',
update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never,
});
expect(request.payload).toMatchObject({
rateType: 'INTERCITY_BULK',
containerTypeId: null,
cargoTypeId: 'cargo-9',
});
expect(request.previousValues).toMatchObject({
rateType: 'INTERCITY_CONTAINER',
containerTypeId: 'ct-1',
});
});
});
describe('approve', () => {

View File

@@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';
/** Fields a change request may carry — anything else in the patch is ignored. */
/**
* Persisted columns an approver is shown a before→after for.
*
* These are RESOLVED entity columns, not raw form fields: the diff runs
* against `RatesService.previewUpdate`, so a change the form expresses through
* a non-stored selector still shows up here as the column it actually moves
* (a flip to bulk lands on `rateType` + the container/cargo swap).
*/
const DIFFABLE_FIELDS = [
'rateValue',
'currency',
@@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The container-vs-bulk shape of the rate. Missing here, switching a LIVE
// rate to bulk showed the approver an empty change list — the only column
// that records the kind is rateType, and the form never posts it directly.
'rateType',
// Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping
// line diffed to nothing.
'shippingLineCompanyId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
@@ -82,7 +96,11 @@ export class RateChangeRequestsService {
);
}
const payload = this.changedFieldsOnly(rate, dto.update);
// Diff the RESOLVED columns, not the raw patch: the form's cargoKind /
// intercityKind selectors are never stored, so a bulk switch only shows up
// once the patch is resolved into the columns it moves.
const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto);
const payload = this.changedFieldsOnly(rate, resolved);
if (Object.keys(payload).length === 0) {
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
}
@@ -98,7 +116,9 @@ export class RateChangeRequestsService {
);
}
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
// previewUpdate above already ran the full validation (it IS buildUpdate),
// so re-validating here would only repeat it — and the trimmed payload is
// resolved columns, not a form patch, so it is not the right input for it.
const request = await this.repo.save(
this.repo.create({
@@ -186,13 +206,14 @@ export class RateChangeRequestsService {
}
/**
* Keep only fields the requester actually changed. A form posts every field
* back, so without this the diff would list untouched values as changes.
* Keep only columns the edit actually moves. `buildUpdate` returns a full
* resolved column set (it re-derives scope on every patch), so without this
* the diff would list every untouched column as a change.
*/
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
private changedFieldsOnly(rate: Rate, resolved: Partial<Rate>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const field of DIFFABLE_FIELDS) {
const proposed = (update as Record<string, unknown>)[field];
const proposed = (resolved as Record<string, unknown>)[field];
if (proposed === undefined) continue;
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
patch[field] = proposed;

View File

@@ -753,6 +753,19 @@ export class RatesService {
await this.buildUpdate(await this.findById(id), dto);
}
/**
* The exact column changes applying this patch would make, without writing.
*
* A change request diffs against THIS rather than the raw patch: the form
* posts selectors that are never stored (`cargoKind`, `intercityKind`), and
* the real edit they encode lands on derived columns — flipping a rate to
* bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing
* the raw patch missed all of it, so the approver saw an empty change list.
*/
async previewUpdate(id: string, dto: UpdateRateDto): Promise<Partial<Rate>> {
return this.buildUpdate(await this.findById(id), dto);
}
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
const updates = await this.buildUpdate(existing, dto);
const updated = await this.repository.update(existing.id, updates);

View File

@@ -867,6 +867,27 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/wagons/export")
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } =
await this.trainSchedulingService.scheduleWagonListWorkbook(id);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
@Get("schedules/:id/export/load-list/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })

View File

@@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -427,6 +446,9 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -3747,6 +3769,132 @@ export class TrainSchedulingService {
};
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
WHERE s.id = $1 AND s.deleted_at IS NULL
ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`,
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
});
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
};
}
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {

View File

@@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -105,4 +105,18 @@ export class ListWagonsQueryDto {
@IsOptional()
@IsDateString()
maintenanceTo?: string;
@ApiPropertyOptional({
description:
'Window (days) the per-row load/move counts are counted over. Does not filter rows.',
default: 90,
minimum: 1,
maximum: 3650,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(3650)
statsWindowDays?: number;
}

View File

@@ -177,6 +177,7 @@ export class WagonsService {
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
await this.attachMovementStats(page.items, query.statsWindowDays ?? 90);
return page;
}
@@ -216,6 +217,56 @@ export class WagonsService {
}
}
/**
* Per-wagon movement rollups for the wagon performance report: when the
* wagon last arrived anywhere (the idle clock), and how many loaded / total
* moves it made inside `windowDays`. One grouped query per page, in the same
* shape as `attachStatusDates` above — never one request per row.
*/
private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise<void> {
if (!wagons.length) return;
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
const rows: Array<{
wagonId: string;
lastMovedAt: Date | null;
loadsInWindow: string;
movesInWindow: string;
emptyMovesInWindow: string;
}> = await this.dataSource
.getRepository(WagonMovement)
.createQueryBuilder('m')
.select('m.wagon_id', 'wagonId')
.addSelect('MAX(m.occurred_at)', 'lastMovedAt')
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)',
'loadsInWindow',
)
.addSelect(
'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)',
'emptyMovesInWindow',
)
.addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow')
.where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.setParameters({
since,
loaded: WagonMovementKind.Loaded,
empty: WagonMovementKind.EmptyReposition,
})
.groupBy('m.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMovedAt: r?.lastMovedAt ?? null,
loadsInWindow: Number(r?.loadsInWindow ?? 0),
movesInWindow: Number(r?.movesInWindow ?? 0),
emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0),
});
}
}
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({
where: { id },
@@ -328,11 +379,35 @@ export class WagonsService {
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
async listMovements(wagonId: string): Promise<WagonMovement[]> {
await this.findById(wagonId); // 404 on unknown wagon
return this.dataSource.getRepository(WagonMovement).find({
const movements = await this.dataSource.getRepository(WagonMovement).find({
where: { wagonId },
relations: { fromYard: true, toYard: true },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
await this.attachBookingReferences(movements);
return movements;
}
/**
* Resolve each loaded move's booking to its human reference, so the UI can
* show (and link to) "BKG-11284" rather than a raw uuid. One query for the
* whole ledger; `wagon_movements` deliberately has no FK to bookings, so
* this is a read-time join on primary keys, exactly like the labels in
* `wagon-history.service`.
*/
private async attachBookingReferences(movements: WagonMovement[]): Promise<void> {
const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))];
if (!ids.length) return;
const rows: Array<{ id: string; reference: string }> = await this.dataSource.query(
`SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`,
[ids],
);
const byId = new Map(rows.map((r) => [r.id, r.reference]));
for (const m of movements) {
Object.assign(m, {
bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null,
});
}
}
async remove(id: string, userId?: string | null): Promise<void> {

View File

@@ -199,9 +199,14 @@ export class WarehouseInventoryController {
@Get('loadable-trains')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
@ApiOperation({
summary:
'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor',
})
loadableTrains(@Query('includeDispatched') includeDispatched?: string) {
return this.inventoryService.loadableTrains({
includeDispatched: includeDispatched === 'true' || includeDispatched === '1',
});
}
@Get('train/:scheduleId/loadable-items')

View File

@@ -2091,7 +2091,17 @@ export class WarehouseInventoryService {
*/
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
async loadableTrains(): Promise<LoadableTrainRow[]> {
/**
* @param includeDispatched also list DISPATCHED trains. Loading follows the
* train after it rolls — a mid-corridor warehouse boards its cargo when the
* train stands at its yard — so the warehouse's train-centric loading view
* needs the same set the schedule workspace offers Load on. The default
* (pre-dispatch only) keeps the existing auto-load picker unchanged.
*/
async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise<LoadableTrainRow[]> {
const statuses = opts.includeDispatched
? ['DRAFT', 'SCHEDULED', 'DISPATCHED']
: ['DRAFT', 'SCHEDULED'];
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
@@ -2129,7 +2139,7 @@ export class WarehouseInventoryService {
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
[statuses],
);
return rows

View File

@@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage";
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
@@ -232,6 +234,24 @@ const App = () => {
</RequirePermission>
}
/>
{/* Wagon performance — a read-only executive report beside Overview.
Separate from the Fleet Management wagons desk, which owns CRUD. */}
<Route
path="wagon-performance"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformancePage />
</RequirePermission>
}
/>
<Route
path="wagon-performance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<WagonPerformanceDetailPage />
</RequirePermission>
}
/>
{/* One drill-down route per overview domain — the old per-tab charts,
now each on its own page. Single source of truth for the
permission gate is OVERVIEW_DOMAINS, shared with the summary

View File

@@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { Container, FileSpreadsheet, Train } from "lucide-react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { formatDate } from "@/lib/format";
import type { BookingWagonRow } from "@/types/trainScheduling";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */
const num = (value: number | string | null | undefined): number => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const tons = (value: number | string | null | undefined): string =>
`${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`;
/** Allocation status → badge colour. PLANNED is the pre-loading default. */
const STATUS_COLORS: Record<string, string> = {
PLANNED: "blue",
LOADED: "edr-green",
UNLOADED: "gray",
CANCELLED: "red",
};
/**
* The booking detail page's "Wagons" tab: every wagon allocated to this booking,
* with its containers or bulk load, plus an Excel export of the same list.
*
* A booking has no wagons until it is paid and placed on a train, so the empty
* state is the normal case for most of a booking's life — it explains the
* precondition rather than reading as an error.
*/
export function BookingWagonsPanel({
bookingId,
bookingReference,
}: {
bookingId: string;
bookingReference: string;
}) {
const [exporting, setExporting] = useState(false);
const { data, isLoading, isError } = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons = useMemo<BookingWagonRow[]>(() => data ?? [], [data]);
const totals = useMemo(() => {
const containerCount = wagons.reduce(
(sum, w) => sum + (w.containers?.length ?? 0),
0,
);
const allocated = wagons.reduce(
(sum, w) => sum + num(w.allocatedWeightTons),
0,
);
const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0);
return { containerCount, allocated, capacity };
}, [wagons]);
// The train is a property of the allocation, so every wagon on this booking
// carries the same one — read it off the first row rather than per row.
const train = wagons[0];
const handleExport = async () => {
setExporting(true);
try {
const blob = await bookingsService.downloadWagonsWorkbook(bookingId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagons-${bookingReference}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason is inside the Blob, so the sync path
// would surface only "Request failed with status code 400".
toast.error(await extractDownloadErrorMessage(error));
} finally {
setExporting(false);
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
return (
<SectionCard
icon={Train}
title="Allocated wagons"
subtitle={
wagons.length
? `${wagons.length} wagon${wagons.length === 1 ? "" : "s"}${
train?.trainNumber ? ` on train ${train.trainNumber}` : ""
}`
: "No wagons allocated yet"
}
extra={
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<FileSpreadsheet size={15} />}
loading={exporting}
// The sheet would be headers with no rows — nothing to hand over.
disabled={wagons.length === 0}
onClick={() => void handleExport()}
>
Export Excel
</Button>
}
>
{isError ? (
<Text size="sm" c="red">
Could not load the wagon allocations for this booking.
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
Wagons appear here once the booking is paid and allocated onto a train.
</Text>
) : (
<Stack gap="lg">
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<MetricTile label="Wagons" value={String(wagons.length)} />
<MetricTile
label="Containers"
value={String(totals.containerCount)}
/>
<MetricTile label="Allocated" value={tons(totals.allocated)} />
<MetricTile label="Capacity" value={tons(totals.capacity)} />
</SimpleGrid>
{train?.departureAt ? (
<Group gap="xs">
<Text size="xs" c="dimmed">
Departs {formatDate(train.departureAt)}
</Text>
{train.originStation && train.destinationStation ? (
<Text size="xs" c="dimmed">
· {train.originStation} {train.destinationStation}
</Text>
) : null}
</Group>
) : null}
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Seq</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Allocated</Table.Th>
<Table.Th ta="right">Capacity</Table.Th>
<Table.Th>Load</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{wagons.map((w) => (
<Table.Tr key={w.allocationId}>
<Table.Td>
<Text size="sm" c="dimmed">
{w.sequenceNo ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{w.wagonNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{w.wagonType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[w.status] ?? "gray"}
>
{w.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text size="sm">{tons(w.allocatedWeightTons)}</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{tons(w.capacityTons)}
</Text>
</Table.Td>
<Table.Td>
{w.containers?.length ? (
<Stack gap={2}>
{w.containers.map((c, i) => (
<Group
key={`${w.allocationId}-${c.containerNumber ?? i}`}
gap={6}
wrap="nowrap"
>
<Container
size={13}
style={{ opacity: 0.5, flexShrink: 0 }}
/>
<Text size="xs">
{c.containerNumber ?? "—"}
{c.sizeFt ? ` · ${c.sizeFt}ft` : ""}
</Text>
</Group>
))}
</Stack>
) : w.bulkCargoDescription || w.loadType === "BULK" ? (
<Text size="xs">
{w.bulkCargoDescription ?? "Bulk"}
{w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
)}
</SectionCard>
);
}

View File

@@ -3,6 +3,7 @@ export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./BookingWagonsPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/auth/http";
import { toDayString } from "@/hooks/useListControls";
import { api as rpc } from "@/services/api";
import { formatMoney } from "@/lib/format";
import {
hasOddFt20,
@@ -13,6 +13,7 @@ import {
type WagonCancellation,
} from "./types";
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
@@ -21,6 +22,46 @@ interface RebookUnitDraft {
vgmTons: number | "";
}
/** Editable unit on the consolidation partner — prefilled from its own cargo. */
interface PartnerUnitDraft {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) =>
(c?.units ?? []).map((u) => ({
id: u.id,
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || ("" as const),
}));
/** Only the units GL actually changed are sent. */
const partnerUnitsPayload = (
drafts: PartnerUnitDraft[],
original: PartnerUnitDraft[],
) =>
drafts
.filter((d, i) => {
const o = original[i];
return (
!o ||
d.containerNumber !== o.containerNumber ||
d.sealNumber !== o.sealNumber ||
d.vgmTons !== o.vgmTons
);
})
.map((d) => ({
id: d.id,
containerNumber: d.containerNumber.trim(),
sealNumber: d.sealNumber.trim(),
...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}),
}));
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
@@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({
/** Called after a successful rebook with the new booking id (when the API returns it). */
onRebooked?: (result: { bookingId?: string }) => void;
}) {
const [date, setDate] = useState<Date | null>(null);
// Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a
// local-midnight Date back with toISOString() shifts it into the previous day
// in any timezone east of UTC (EAT is +03), which both mis-rendered the
// selection and submitted the wrong shipment day.
const [date, setDate] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [partnerDrafts, setPartnerDrafts] = useState<PartnerUnitDraft[]>([]);
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
// Fresh form per row: the modal instance is long-lived on the host page.
useEffect(() => {
setDate(null);
setPartnerId(null);
setPartnerDrafts([]);
setDrafts(cancellation ? draftsFrom(cancellation) : []);
}, [cancellation]);
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
// The rebook rides the same lane with the same cargo as the cancelled
// shipment, so the shipment day must come from the days that lane actually
// runs — an arbitrary calendar day has no train and no wagon capacity.
const daysQuery = useMemo(() => {
const b = cancellation?.booking;
if (!b?.originYardId || !b?.destinationYardId) return null;
const containers = Object.entries(
cancellation?.cancelledQuantities?.bySize ?? {},
)
.map(([containerSize, quantity]) => ({
containerSize,
quantity: Number(quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length > 0) {
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "CONTAINER" as const,
containers,
};
}
const tons = Number(cancellation?.weightTons || 0);
if (tons <= 0) return null;
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "BULK" as const,
totalWeightTons: tons,
};
}, [cancellation]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...rpc.trainScheduling.availableDaysForCargo.queryOptions({
input: daysQuery ?? { freightType: "BULK" as const },
}),
enabled: Boolean(cancellation) && daysQuery !== null,
});
const partners = useQuery({
queryKey: [
"wagon-cancellations",
cancellation?.id,
"rebook-partners",
date ? toDayString(date) : null,
date,
],
enabled: Boolean(cancellation && needsPartner && date),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(date!) } },
{ params: { scheduledDate: date } },
);
return res.data;
},
@@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({
const res = await api.post<{ bookingId?: string }>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
{
scheduledDate: toDayString(date!),
scheduledDate: date,
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
...(partnerId ? { partnerBookingId: partnerId } : {}),
...(() => {
if (!partnerId) return {};
const original = partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === partnerId),
);
const changed = partnerUnitsPayload(partnerDrafts, original);
return changed.length ? { partnerUnits: changed } : {};
})(),
},
);
return res.data ?? {};
},
});
const patchPartnerDraft = (i: number, patch: Partial<PartnerUnitDraft>) =>
setPartnerDrafts((prev) =>
prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)),
);
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
@@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({
title="Rebook cancelled wagons"
centered
radius="md"
// Wide enough for the calendar plus two container-unit editors side by
// side without the number / seal / VGM fields cramping.
size="xl"
>
{cancellation && (
<Stack gap="sm">
@@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={date}
onChange={(v) => {
setDate(v ? new Date(v) : null);
<Text size="sm" fw={600}>
Shipment day
</Text>
<OperationDatePicker
fullWidth
availableDays={daysQuery === null ? [] : (availableDays ?? [])}
isLoading={daysQuery !== null && daysLoading}
emptyMessage={
daysQuery === null
? "This cancellation has no route or cargo on record — the available shipment days cannot be worked out."
: "No train day on this route can take this cargo right now."
}
value={date ?? ""}
onChange={(d) => {
setDate(d || null);
setPartnerId(null);
setPartnerDrafts([]);
}}
minDate={new Date()}
radius="md"
/>
{needsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
withAsterisk
placeholder={
!date
? "Pick the day first"
@@ -150,7 +261,14 @@ export function RebookWagonCancellationModal({
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={partnerId}
onChange={setPartnerId}
onChange={(v) => {
setPartnerId(v);
setPartnerDrafts(
partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === v),
),
);
}}
disabled={!date}
searchable
radius="md"
@@ -161,12 +279,66 @@ export function RebookWagonCancellationModal({
!partners.isLoading &&
(partners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait for
a partner booking.
No odd-20ft booking rides that day pick another day, or wait
for a booking that can share this wagon.
</Text>
)}
{partnerId && partnerDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
Partner containers
</Text>
<Text size="xs" c="dimmed">
Correct the partner booking's own container details if they
changed — sizes and quantities stay as booked.
</Text>
{partnerDrafts.map((d, i) => (
<Group key={d.id} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize || "Container"}`}
value={d.containerNumber}
onChange={(e) =>
patchPartnerDraft(i, {
containerNumber: e.currentTarget.value,
})
}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) =>
patchPartnerDraft(i, { sealNumber: e.currentTarget.value })
}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
patchPartnerDraft(i, {
vgmTons: raw === "" ? "" : Number(raw),
});
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
{drafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
This booking's containers
</Text>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.

View File

@@ -29,6 +29,11 @@ export interface WagonCancellation {
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
/** Route of the cancelled shipment — the rebook rides the same lane, so the
* shipment-day picker offers only days that lane actually runs. */
originYardId?: string | null;
destinationYardId?: string | null;
freightType?: string | null;
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
@@ -48,6 +53,14 @@ export interface WagonCancellationListResponse {
total: number;
}
export interface RebookPartnerUnit {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string | null;
vgmTons: number;
}
export interface RebookPartnerCandidate {
id: string;
reference: string;
@@ -55,6 +68,8 @@ export interface RebookPartnerCandidate {
status: string;
scheduledDate: string | null;
ft20Quantity: number;
/** The partner's own container units — editable while pairing. */
units?: RebookPartnerUnit[];
}
export const WAGON_CANCELLATION_STATUS_CHIP: Record<

View File

@@ -18,8 +18,8 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
* direction, same booking day, customs clearing, an odd 20ft count of their own
* and not already linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
@@ -55,8 +55,8 @@ export function ConsolidationPartnerPicker({
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
Customs bookings on the same route and booking day that also carry
an odd number of 20ft containers.
</Text>
</Box>
</Group>
@@ -84,9 +84,10 @@ export function ConsolidationPartnerPicker({
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
No other customs booking on this route and booking day currently
carries an odd number of 20ft containers. Either wait for one, or
switch the shared-wagon option off and book an even number of 20ft
containers.
</Text>
</Alert>
) : (
@@ -113,9 +114,7 @@ export function ConsolidationPartnerPicker({
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
{`${candidate.ft20Quantity} × 20ft`}
</Text>
</Box>
<Button

View File

@@ -69,6 +69,12 @@ export const buildSidebarSections = (
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Wagon Performance",
href: "/dashboard/wagon-performance",
icon: <TrainFront />,
permission: FREIGHT_PERMS.wagons.view,
},
{
label: "Customers",
href: "/dashboard/customers",

View File

@@ -11,8 +11,10 @@ import {
Loader,
Menu,
Modal,
MultiSelect,
NumberInput,
ScrollArea,
SegmentedControl,
Select,
SimpleGrid,
Stack,
@@ -91,6 +93,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { TrainLoadingWorkspace } from './TrainLoadingWorkspace';
import { YardLoadingWindows } from './YardLoadingWindows';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
@@ -1861,6 +1864,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
const qc = useQueryClient();
// "Items" is the inventory list with the auto-load picker; "By train" mirrors
// the train schedule's per-booking Load / Wagons / Unload workspace here.
const [view, setView] = useState<'items' | 'train'>('items');
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
@@ -1952,16 +1958,50 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
}
};
if (view === 'train') {
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between" wrap="wrap">
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as 'items' | 'train')}
data={[
{ value: 'items', label: 'Items' },
{ value: 'train', label: 'By train' },
]}
/>
<Text size="xs" c="dimmed">
Per-booking Load, wagon-by-wagon loading and unloading the train schedule&apos;s own
actions, run from the warehouse.
</Text>
</Group>
<TrainLoadingWorkspace enabled={enabled} onChanged={onChanged} />
</Stack>
);
}
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Group justify="space-between" wrap="wrap">
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as 'items' | 'train')}
data={[
{ value: 'items', label: 'Items' },
{ value: 'train', label: 'By train' },
]}
/>
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
</Group>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
<Button
size="compact-sm"

View File

@@ -0,0 +1,927 @@
import { useEffect, useMemo, useState } from 'react';
import { isAxiosError } from 'axios';
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowRight,
CheckCircle2,
Circle,
ExternalLink,
Info,
MapPin,
PackageCheck,
PackageOpen,
Train,
TrainFront,
Weight,
XCircle,
} from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge';
import { EntityLink } from '@/components/detail';
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
import { useToast } from '@/hooks/use-toast';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import type { YardWorkBookingRow } from '@/types/trainScheduling';
import { extractErrorMessage, formatDate } from './options';
import { TrainWagonLoadModal } from './TrainWagonLoadModal';
/**
* Train-centric loading workspace for the warehouse — a mirror of the train
* schedule's "On this train" column, placed where the warehouse actually
* loads.
*
* Nothing here has its own rules. The train position, the per-yard loading /
* unloading windows, and the per-booking Load / Wagons / Unload actions all
* come from the train-scheduling endpoints the schedule workspace uses
* (`yard-work`, `schedules/:id/bookings/:bookingId/load|unload`, the per-wagon
* variants). The warehouse only ADDS what the schedule cannot see: which of the
* train's bookings are physically in the shed, with GRN and inspection state.
* So the two screens can never disagree — a Load that would be refused on the
* schedule is disabled here with the same reason.
*
* International port-ops shape the layout follows: cargo is worked per stop,
* only where the train stands, inside an opened work window, wagon by wagon,
* with a pre-load checklist (paid → received/GRN → inspected → wagon pinned →
* window open → train here) visible before anyone presses Load.
*/
const PAID_OR_LATER = new Set([
'PAID',
'FULLY_EXECUTED',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'DELIVERED',
]);
interface WarehouseSummary {
items: TrainLoadableItem[];
loaded: number;
ready: number;
hasGrn: boolean;
inspection: string | null;
wagons: string[];
weight: number;
}
function summarise(items: TrainLoadableItem[]): WarehouseSummary {
const wagons = [
...new Set(items.map((i) => i.wagonNumber).filter((w): w is string => Boolean(w))),
];
const inspections = items.map((i) => i.inspectionStatus).filter(Boolean) as string[];
return {
items,
loaded: items.filter((i) => i.status === 'LOADED').length,
ready: items.filter((i) => i.status === 'READY_FOR_LOADING').length,
hasGrn: items.some((i) => Boolean(i.grnNumber)),
inspection: inspections.includes('FAILED')
? 'FAILED'
: inspections.length && inspections.every((s) => s === 'PASSED')
? 'PASSED'
: (inspections[0] ?? null),
wagons,
weight: items.reduce((sum, i) => sum + (Number(i.weight) || 0), 0),
};
}
/** One pre-load gate: green when met, red when blocking, grey when only informative. */
function Gate({
ok,
label,
neutral,
hint,
}: {
ok: boolean;
label: string;
neutral?: boolean;
hint?: string;
}) {
const color = ok ? 'edr-green' : neutral ? 'gray' : 'red';
const Icon = ok ? CheckCircle2 : neutral ? Circle : XCircle;
const badge = (
<Badge
size="xs"
radius="sm"
variant={ok ? 'light' : 'outline'}
color={color}
leftSection={<Icon size={10} />}
style={{ textTransform: 'none' }}
>
{label}
</Badge>
);
return hint ? (
<Tooltip label={hint} withArrow>
{badge}
</Tooltip>
) : (
badge
);
}
const FLOW_STEPS = [
'Receive · GRN',
'Inspect',
'Ready',
'Open loading window',
'Train at yard',
'Load per wagon',
'Dispatch',
'Unload at port',
];
export function TrainLoadingWorkspace({
enabled = true,
onChanged,
}: {
enabled?: boolean;
onChanged?: () => void;
}) {
const { toast } = useToast();
const { user } = useAuth();
const qc = useQueryClient();
const canView = hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
const canLoad = hasPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasPermission(user, FREIGHT_PERMS.trainScheduling.unload);
// Trains with warehouse cargo, rolling ones included: loading follows the
// train along the corridor, exactly as the schedule workspace allows.
const trainsQuery = useQuery({
queryKey: ['loadable-trains', { includeDispatched: true }],
queryFn: () => warehouseService.getLoadableTrains({ includeDispatched: true }),
enabled,
});
const trains = trainsQuery.data ?? [];
const [scheduleId, setScheduleId] = useState<string | null>(null);
useEffect(() => {
if (!trains.length) return;
if (scheduleId && trains.some((t) => t.scheduleId === scheduleId)) return;
// Prefer a train that still has cargo waiting; otherwise the first one.
const pick = trains.find((t) => t.readyCount > 0) ?? trains[0];
setScheduleId(pick.scheduleId);
}, [trains, scheduleId]);
const train = trains.find((t) => t.scheduleId === scheduleId) ?? null;
const ready = enabled && canView && Boolean(scheduleId);
const detailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? '' },
enabled: ready,
}),
);
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId: scheduleId ?? '' },
enabled: ready,
refetchInterval: 60_000,
}),
);
const itemsQuery = useQuery({
queryKey: ['train-loadable-items', scheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId!),
enabled: enabled && Boolean(scheduleId),
});
const schedule = detailQuery.data ?? null;
const yardWork = yardWorkQuery.data ?? null;
const items = itemsQuery.data ?? [];
const [onlyInWarehouse, setOnlyInWarehouse] = useState(false);
// ── Corridor + position (same derivation as the schedule workspace) ───────
const stations = useMemo(() => {
const stops = schedule?.stops ?? [];
if (stops.length) return stops;
return [
{
yardId: schedule?.originStation?.id ?? train?.originStationId ?? 'origin',
label: schedule?.originStation?.label ?? train?.origin ?? 'Origin',
},
{
yardId: schedule?.destinationStation?.id ?? 'destination',
label: schedule?.destinationStation?.label ?? train?.destination ?? 'Destination',
},
];
}, [schedule, train]);
const stationIdx = useMemo(() => new Map(stations.map((s, i) => [s.yardId, i])), [stations]);
const trainAtYardId = yardWork?.trainAtYardId ?? null;
const trainIdx = trainAtYardId != null ? (stationIdx.get(trainAtYardId) ?? null) : null;
const trainAtLabel = trainIdx != null ? stations[trainIdx]?.label : null;
const workLogs = yardWork?.stationWorkLogs ?? schedule?.stationWorkLogs ?? {};
const journeyById = useMemo(() => {
const map = new Map<string, YardWorkBookingRow>();
for (const yard of yardWork?.yards ?? []) {
for (const row of [...yard.toLoad, ...yard.toUnload]) map.set(row.id, row);
}
return map;
}, [yardWork]);
const warehouseByBooking = useMemo(() => {
const groups = new Map<string, TrainLoadableItem[]>();
for (const item of items) {
if (!item.bookingId) continue;
const list = groups.get(item.bookingId) ?? [];
list.push(item);
groups.set(item.bookingId, list);
}
return new Map([...groups.entries()].map(([id, list]) => [id, summarise(list)]));
}, [items]);
const onTrain = useMemo(() => {
const all = schedule?.bookings ?? [];
return onlyInWarehouse ? all.filter((b) => warehouseByBooking.has(b.id)) : all;
}, [schedule, onlyInWarehouse, warehouseByBooking]);
const corridorGroups = useMemo(() => {
const groups = new Map<string, { yardId: string; label: string; rows: typeof onTrain }>();
for (const b of onTrain) {
const yardId =
b.originYardId && stationIdx.has(b.originYardId)
? b.originYardId
: (stations[0]?.yardId ?? 'origin');
let group = groups.get(yardId);
if (!group) {
group = {
yardId,
label: stations[stationIdx.get(yardId) ?? 0]?.label ?? b.origin ?? 'Origin',
rows: [],
};
groups.set(yardId, group);
}
group.rows.push(b);
}
return [...groups.values()].sort(
(a, b) => (stationIdx.get(a.yardId) ?? 0) - (stationIdx.get(b.yardId) ?? 0),
);
}, [onTrain, stationIdx, stations]);
// Bookings alighting where the train stands — the unload side of the mirror.
const alightingHere = useMemo(
() =>
trainAtYardId
? (schedule?.bookings ?? []).filter(
(b) => b.destinationYardId === trainAtYardId && b.status === 'IN_TRANSIT',
)
: [],
[schedule, trainAtYardId],
);
const canWork = ['DRAFT', 'SCHEDULED', 'DISPATCHED'].includes(schedule?.status ?? '');
// ── Actions — the schedule's own endpoints ────────────────────────────────
const loadJourney = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const unloadJourney = useMutation(api.trainScheduling.unloadScheduleBooking.mutationOptions());
const [confirmAction, setConfirmAction] = useState<{
kind: 'load' | 'unload';
bookingId: string;
ref: string;
} | null>(null);
const [wagonModal, setWagonModal] = useState<{
bookingId: string;
ref: string;
phase: 'load' | 'unload';
} | null>(null);
const afterChange = () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['train-loadable-items'] });
void qc.invalidateQueries({ queryKey: ['loadable-trains'] });
void yardWorkQuery.refetch();
void detailQuery.refetch();
onChanged?.();
};
const runConfirmedAction = () => {
if (!confirmAction || !scheduleId) return;
const { kind, bookingId, ref } = confirmAction;
setConfirmAction(null);
const mutation = kind === 'load' ? loadJourney : unloadJourney;
mutation
.mutateAsync({ scheduleId, bookingId })
.then(() => {
toast({
title: kind === 'load' ? `${ref} loaded onto the train` : `${ref} unloaded at this yard`,
});
afterChange();
})
.catch((error) =>
toast({
variant: 'destructive',
title: kind === 'load' ? 'Could not load booking' : 'Could not unload booking',
description: extractErrorMessage(
error,
kind === 'load'
? 'The train must be at the boarding yard with its loading window started.'
: 'The train must be at the destination yard with its unloading window started.',
),
}),
);
};
// ── Empty / blocked states ────────────────────────────────────────────────
if (!canView) {
return (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
This view reads the train&apos;s position and journey from train scheduling. Ask for the
<b> train scheduling: view</b> permission to use it.
</Alert>
);
}
if (trainsQuery.isLoading) {
return (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
);
}
if (trains.length === 0) {
return (
<Alert color="gray" variant="light" icon={<Info size={16} />}>
No train is boarding warehouse cargo right now. A train appears here once it is scheduled
with wagons allocated to bookings whose goods have been received at the warehouse.
</Alert>
);
}
const forbidden =
(isAxiosError(detailQuery.error) && detailQuery.error.response?.status === 403) ||
(isAxiosError(yardWorkQuery.error) && yardWorkQuery.error.response?.status === 403);
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'}${t.destination ?? '?'} · dep ${
t.departureTime ? formatDate(t.departureTime) : '—'
} · ${t.readyCount} to load · ${t.loadedCount} loaded${t.status === 'DISPATCHED' ? ' · rolling' : ''}`,
}));
return (
<Stack gap="md">
{/* Train picker + position */}
<Paper withBorder radius="lg" p="md">
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Select
label="Train"
description="Trains with cargo of this warehouse allocated to their wagons"
data={trainOptions}
value={scheduleId}
onChange={(v) => v && setScheduleId(v)}
searchable
allowDeselect={false}
miw={420}
maw={640}
style={{ flex: 1 }}
/>
<Group gap={8} wrap="wrap">
{schedule ? (
<Badge size="sm" radius="sm" variant="light" color={canWork ? 'edr-green' : 'gray'}>
{String(schedule.status).replace(/_/g, ' ')}
</Badge>
) : null}
<Badge
size="sm"
radius="sm"
variant={trainAtLabel ? 'filled' : 'outline'}
color={trainAtLabel ? 'edr-green' : 'gray'}
leftSection={<TrainFront size={11} />}
>
{trainAtLabel ? `Train at ${trainAtLabel}` : 'Position unknown'}
</Badge>
{scheduleId ? (
<EntityLink
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
label="Open schedule"
icon={ExternalLink}
size="xs"
/>
) : null}
</Group>
</Group>
{/* Port-ops flow the actions below follow */}
<Group gap={4} mt="sm" wrap="wrap" align="center">
{FLOW_STEPS.map((step, i) => (
<Group key={step} gap={4} wrap="nowrap" align="center">
<Text size="xs" c="dimmed">
{step}
</Text>
{i < FLOW_STEPS.length - 1 ? (
<ArrowRight size={11} color="var(--mantine-color-gray-5)" />
) : null}
</Group>
))}
</Group>
</Paper>
{forbidden ? (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
The train-scheduling API refused the journey read for this train. The
<b> train scheduling: view</b> permission is required to load from here.
</Alert>
) : detailQuery.isLoading || yardWorkQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="xs" c="dimmed">
Load is only offered where the train actually stands, inside a started loading window
the same rule the train schedule enforces. Removing a booking from the train,
cancelling wagons and direct truck-to-train stay on the schedule workspace.
</Text>
<Checkbox
size="xs"
label="Only bookings with cargo in the warehouse"
checked={onlyInWarehouse}
onChange={(e) => setOnlyInWarehouse(e.currentTarget.checked)}
/>
</Group>
{corridorGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
{onlyInWarehouse
? 'None of this trains bookings have cargo in the warehouse yet.'
: 'No bookings allocated to this train yet.'}
</Text>
) : null}
{corridorGroups.map((group) => {
const groupIdx = stationIdx.get(group.yardId) ?? 0;
const trainHere = trainAtYardId === group.yardId;
const passed = trainIdx != null && groupIdx < trainIdx;
const loadLog = workLogs[group.yardId]?.loading;
return (
<Paper key={group.yardId} withBorder radius="md" p="sm">
<Stack gap={8}>
<Group gap={8} align="center" wrap="wrap">
<MapPin size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" fw={700}>
{group.label}
</Text>
{trainHere ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color="edr-green"
leftSection={<TrainFront size={11} />}
>
Train here
</Badge>
) : passed ? (
<Badge size="sm" radius="sm" variant="light" color="gray">
Passed
</Badge>
) : (
<Badge size="sm" radius="sm" variant="outline" color="gray">
Ahead
</Badge>
)}
<Badge size="sm" radius="sm" variant="light" color="gray">
{group.rows.length}
</Badge>
</Group>
{/* The yard's loading window — same store the schedule writes. */}
{scheduleId && (trainHere || loadLog?.startedAt) ? (
<Box
p="xs"
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
background: 'var(--mantine-color-gray-0)',
}}
>
<StationWorkControls
scheduleId={scheduleId}
yardId={group.yardId}
phase="loading"
log={loadLog}
/>
</Box>
) : null}
{group.rows.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
const wh = warehouseByBooking.get(b.id) ?? null;
const loadWindowStarted = Boolean(loadLog?.startedAt);
const riding = b.status === 'IN_TRANSIT';
const done = ['ARRIVED', 'COMPLETED', 'DELIVERED'].includes(b.status ?? '');
const boardHere = trainHere;
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
const paid = PAID_OR_LATER.has(b.status ?? '');
const wagonPinned = Boolean(b.wagonAssigned) || Boolean(wh?.wagons.length);
const allLoaded =
riding ||
Boolean(b.loadedAt) ||
(wh != null && wh.loaded === wh.items.length && wh.items.length > 0);
return (
<Paper key={b.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" wrap="wrap">
<EntityLink
to={`/dashboard/booking-requests/${b.id}`}
label={ref}
size="sm"
fw={700}
/>
{b.status ? <BookingStatusBadge status={b.status} /> : null}
{b.tradeDirection === 'DOMESTIC' ? (
<Badge size="sm" radius="sm" variant="filled" color="indigo">
Intercity
</Badge>
) : null}
{riding || Boolean(b.loadedAt) || wagonPinned ? (
<Badge
size="sm"
radius="sm"
variant={allLoaded ? 'filled' : 'light'}
color={allLoaded ? 'edr-green' : 'gray'}
>
{allLoaded
? 'Loaded'
: wh && wh.loaded > 0
? `Partly loaded ${wh.loaded}/${wh.items.length}`
: 'Unloaded'}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" c="dimmed" truncate>
{b.customer ?? '—'}
</Text>
{b.weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(b.weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
{b.origin &&
b.destination &&
(b.originYardId !== schedule?.originStation?.id ||
b.destinationYardId !== schedule?.destinationStation?.id) ? (
<Text
size="xs"
c="indigo.7"
fw={600}
style={{ whiteSpace: 'nowrap' }}
>
{b.origin} {b.destination}
</Text>
) : null}
{wh ? (
<Text size="xs" c="dimmed">
{wh.items.length} item
{wh.items.length === 1 ? '' : 's'} in warehouse
{wh.wagons.length ? ` · wagon ${wh.wagons.join(', ')}` : ''}
{wh.items[0]?.grnNumber ? ` · ${wh.items[0].grnNumber}` : ''}
</Text>
) : (
<Text size="xs" c="orange.7">
Not received at the warehouse
</Text>
)}
</Group>
{/* Pre-load checklist — every server gate, visible before Load. */}
{!riding && !done ? (
<Group gap={4} wrap="wrap">
<Gate ok={paid} label="Paid" hint="Only a paid booking may board" />
<Gate
ok={Boolean(wh?.hasGrn)}
label="Received · GRN"
hint="Export cargo rides only after it was received at the warehouse and a GRN was raised"
/>
<Gate
ok={wh?.inspection === 'PASSED'}
neutral={wh?.inspection !== 'FAILED'}
label={
wh?.inspection === 'PASSED'
? 'Inspected'
: wh?.inspection === 'FAILED'
? 'Inspection failed'
: 'Not inspected'
}
hint="Inspection is recorded on the goods; it does not block loading"
/>
<Gate
ok={wagonPinned}
label="Wagon"
hint="A wagon must be pinned to the booking on this train"
/>
<Gate
ok={loadWindowStarted}
label="Loading window"
hint={`Start loading at ${group.label} on this train first`}
/>
<Gate
ok={boardHere}
label="Train here"
neutral={!passed}
hint={
boardHere
? `The train stands at ${group.label}`
: passed
? `The train already passed ${group.label}`
: `The train is ${trainAtLabel ? `at ${trainAtLabel}` : 'not here yet'}`
}
/>
</Group>
) : null}
</Stack>
<Group gap={6} wrap="nowrap" justify="flex-end" style={{ flexShrink: 0 }}>
{showLoad ? (
<Tooltip
label={
!canLoad
? "You don't have permission to load cargo"
: boardHere && !loadWindowStarted
? `Start loading at ${group.label} first`
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : 'not there yet'
}`
}
withArrow
>
<Button
size="compact-sm"
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere || !canLoad || !loadWindowStarted}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
loadJourney.variables?.bookingId === b.id
}
onClick={() =>
setConfirmAction({
kind: 'load',
bookingId: b.id,
ref,
})
}
>
Load
</Button>
</Tooltip>
) : null}
{showLoad && boardHere ? (
<Tooltip label="Load wagon by wagon" withArrow>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
disabled={!canLoad || !loadWindowStarted}
onClick={() =>
setWagonModal({
bookingId: b.id,
ref,
phase: 'load',
})
}
>
Wagons
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
})}
</Stack>
</Paper>
);
})}
{/* Unload side — bookings alighting where the train stands (port arrival). */}
{trainAtYardId && alightingHere.length > 0 ? (
<Paper withBorder radius="md" p="sm">
<Stack gap={8}>
<Group gap={8} align="center" wrap="wrap">
<PackageOpen size={13} color="var(--mantine-color-orange-6)" />
<Text size="xs" fw={700}>
Unload at {trainAtLabel ?? 'this yard'}
</Text>
<Badge size="sm" radius="sm" variant="light" color="orange">
{alightingHere.length}
</Badge>
</Group>
{scheduleId ? (
<Box
p="xs"
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
background: 'var(--mantine-color-gray-0)',
}}
>
<StationWorkControls
scheduleId={scheduleId}
yardId={trainAtYardId}
phase="unloading"
log={workLogs[trainAtYardId]?.unloading}
/>
</Box>
) : null}
{alightingHere.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
const unloadWindowStarted = Boolean(
workLogs[trainAtYardId]?.unloading?.startedAt,
);
const showUnload = canWork && (journey?.canUnload ?? false);
return (
<Paper key={b.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<EntityLink
to={`/dashboard/booking-requests/${b.id}`}
label={ref}
size="sm"
fw={700}
/>
{b.status ? <BookingStatusBadge status={b.status} /> : null}
</Group>
<Group gap={10} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{b.customer ?? '—'}
</Text>
{b.weightTons != null ? (
<Text size="xs" c="dimmed">
{Number(b.weightTons).toFixed(1)}T
</Text>
) : null}
</Group>
</Stack>
<Group gap={6} wrap="nowrap" justify="flex-end">
{showUnload ? (
<>
<Tooltip
label={
!canUnload
? "You don't have permission to unload cargo"
: !unloadWindowStarted
? 'Start unloading at this yard first'
: "Unload at this yard — stamps the booking's arrival"
}
withArrow
>
<Button
size="compact-sm"
variant="light"
color="orange"
radius="md"
disabled={!canUnload || !unloadWindowStarted}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&
unloadJourney.variables?.bookingId === b.id
}
onClick={() =>
setConfirmAction({
kind: 'unload',
bookingId: b.id,
ref,
})
}
>
Unload
</Button>
</Tooltip>
<Tooltip label="Unload wagon by wagon" withArrow>
<Button
size="compact-sm"
variant="light"
color="orange"
radius="md"
disabled={!canUnload || !unloadWindowStarted}
onClick={() =>
setWagonModal({
bookingId: b.id,
ref,
phase: 'unload',
})
}
>
Wagons
</Button>
</Tooltip>
</>
) : null}
</Group>
</Group>
</Paper>
);
})}
</Stack>
</Paper>
) : null}
</Stack>
)}
{wagonModal && scheduleId ? (
<TrainWagonLoadModal
scheduleId={scheduleId}
bookingId={wagonModal.bookingId}
reference={wagonModal.ref}
phase={wagonModal.phase}
onClose={() => setWagonModal(null)}
onChanged={afterChange}
/>
) : null}
{/* Confirm load / unload — same wording as the schedule workspace */}
<Modal
opened={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
confirmAction ? (
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={confirmAction.kind === 'unload' ? 'orange' : 'edr-green'}
>
{confirmAction.kind === 'unload' ? (
<PackageOpen size={21} />
) : (
<PackageCheck size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>
{confirmAction.kind === 'unload'
? 'Unload cargo at this yard?'
: 'Load cargo onto the train?'}
</Text>
<Text size="xs" c="dimmed">
{confirmAction.ref}
</Text>
</div>
</Group>
) : null
}
>
{confirmAction ? (
<Stack gap="md">
<Text size="sm">
{confirmAction.kind === 'unload'
? "Stamps the booking's arrival at this yard and frees its wagons for reuse."
: 'Stamps the booking as loaded at this yard and moves its warehouse inventory to LOADED. The server checks the train is actually standing here.'}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
Cancel
</Button>
<Button
color={confirmAction.kind === 'unload' ? 'orange' : 'edr-green'}
radius="md"
leftSection={<Train size={14} />}
onClick={runConfirmedAction}
>
{confirmAction.kind === 'unload' ? 'Unload' : 'Load'}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,320 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Modal,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Info, PackageCheck, PackageOpen, Train } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { api } from '@/services/api';
import type { BookingWagonRow } from '@/types/trainScheduling';
import { extractErrorMessage } from './options';
/**
* Wagon-by-wagon load / unload of one booking on one train — the warehouse
* mirror of the train schedule's "Wagons" button.
*
* It calls the SAME per-wagon journey endpoints the schedule workspace calls
* (`schedules/:id/bookings/:bookingId/wagons/:allocationId/load|unload`), so
* every server gate — train at the yard, loading window started, PAID, GRN —
* is the schedule's own, and the two surfaces can never disagree on what got
* loaded. Wagons go one at a time in order: the server flips the booking to
* IN_TRANSIT / ARRIVED on whichever call clears the last wagon, so sequential
* is required, not just convenient. A failure stops the run; the wagons
* already sent stay done and the toast says how many, so a retry only resends
* the rest.
*
* Deliberately NOT mirrored here: cancelling wagons that will not ride and the
* direct truck-to-train handover. Both are commercial/allocation decisions
* (fees, credits, GRN waiver) that belong to the train schedule workspace, not
* the warehouse floor.
*/
export function TrainWagonLoadModal({
scheduleId,
bookingId,
reference,
phase,
onClose,
onChanged,
}: {
scheduleId: string;
bookingId: string;
reference: string;
phase: 'load' | 'unload';
onClose: () => void;
onChanged?: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [picked, setPicked] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const wagonsQuery = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
const isDone = (w: BookingWagonRow) =>
phase === 'load' ? w.status === 'LOADED' || w.status === 'DEPARTED' : w.status === 'DEPARTED';
const doneCount = wagons.filter(isDone).length;
const pending = wagons.filter((w) => !isDone(w));
const pickedPending = pending.filter((w) => picked.has(w.allocationId));
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
const unloadWagon = useMutation(api.trainScheduling.unloadScheduleBookingWagon.mutationOptions());
const act = phase === 'load' ? loadWagon : unloadWagon;
const toggle = (allocationId: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const afterChange = () => {
void wagonsQuery.refetch();
// The warehouse queues read inventory status, which the journey load moves.
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['train-loadable-items'] });
void qc.invalidateQueries({ queryKey: ['loadable-trains'] });
onChanged?.();
};
const submit = async () => {
const targets = pickedPending;
if (!targets.length) return;
setConfirmOpen(false);
setSubmitting(true);
let done = 0;
let completed = false;
try {
for (const w of targets) {
const r = await act.mutateAsync({
scheduleId,
bookingId,
allocationId: w.allocationId,
});
done += 1;
if (r.completed) completed = true;
}
afterChange();
setPicked(new Set());
if (completed) {
toast({
title: phase === 'load' ? 'Booking fully loaded' : 'Booking fully unloaded',
description:
phase === 'load'
? `${reference}: every wagon is loaded — the booking is in transit.`
: `${reference}: every wagon is unloaded — the booking arrived.`,
});
onClose();
} else {
toast({
title: phase === 'load' ? 'Wagons loaded' : 'Wagons unloaded',
description: `${reference}: ${done} wagon${done === 1 ? '' : 's'} ${phase === 'load' ? 'loaded' : 'unloaded'}.`,
});
}
} catch (error) {
if (done > 0) afterChange();
toast({
variant: 'destructive',
title: phase === 'load' ? 'Wagon load failed' : 'Wagon unload failed',
description: done
? `${done} wagon(s) went through before this: ${extractErrorMessage(error)}`
: extractErrorMessage(error),
});
} finally {
setSubmitting(false);
}
};
const color = phase === 'load' ? 'edr-green' : 'orange';
const Icon = phase === 'load' ? PackageCheck : PackageOpen;
return (
<Modal
opened
onClose={onClose}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
{phase === 'load' ? 'Load' : 'Unload'} {reference} wagon by wagon
</Text>
</Group>
}
centered
radius="lg"
size="lg"
>
<Stack gap="sm">
<Group gap={8}>
<Badge size="sm" radius="sm" variant="light" color={doneCount ? color : 'gray'}>
{doneCount}/{wagons.length} {phase === 'load' ? 'loaded' : 'unloaded'}
</Badge>
{phase === 'load' && doneCount > 0 && pending.length > 0 ? (
<Text size="xs" c="dimmed">
The train cannot dispatch until the rest are loaded or cancelled.
</Text>
) : null}
</Group>
{wagonsQuery.isLoading ? (
<Text size="sm" c="dimmed">
Loading wagons
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon allocations yet use the whole-booking button instead.
</Text>
) : (
wagons.map((w) => (
<Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{!isDone(w) ? (
<Checkbox
checked={picked.has(w.allocationId)}
onChange={() => toggle(w.allocationId)}
disabled={submitting}
color={color}
aria-label={`Select wagon ${w.sequenceNo ?? ''} to ${phase}`}
/>
) : null}
<Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : '—'}
</Badge>
<Text size="sm" fw={600} truncate>
{w.wagonNumber ?? w.wagonType ?? 'Wagon'}
</Text>
<Text size="xs" c="dimmed">
{w.wagonTypeCode ?? ''}
{w.allocatedWeightTons ? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T` : ''}
{w.containers?.length ? ` · ${w.containers.length} ctr` : ''}
</Text>
</Group>
{isDone(w) ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color={color}
leftSection={<CheckCircle2 size={11} />}
>
{phase === 'load' ? 'Loaded' : 'Unloaded'}
</Badge>
) : null}
</Group>
</Paper>
))
)}
{pending.length > 0 && !confirmOpen ? (
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap={8}>
<Button
size="compact-sm"
variant="subtle"
radius="md"
disabled={submitting}
onClick={() =>
setPicked(
picked.size === pending.length
? new Set()
: new Set(pending.map((w) => w.allocationId)),
)
}
>
{picked.size === pending.length ? 'Clear all' : 'Select all'}
</Button>
<Text size="xs" c="dimmed">
{pickedPending.length} of {pending.length} selected
</Text>
</Group>
<Tooltip
label={
phase === 'load'
? 'Load the selected wagons — export cargo must already be received at the warehouse with a GRN.'
: 'Unload the selected wagons.'
}
withArrow
>
<Button
color={color}
radius="md"
leftSection={<Icon size={14} />}
disabled={!pickedPending.length || submitting}
loading={submitting}
onClick={() => setConfirmOpen(true)}
>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length || ''}
</Button>
</Tooltip>
</Group>
) : null}
{confirmOpen ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap={10} wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color={color}>
<Icon size={21} />
</ThemeIcon>
<div>
<Text fw={800}>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length} wagon
{pickedPending.length === 1 ? '' : 's'}?
</Text>
<Text size="xs" c="dimmed">
{reference}
</Text>
</div>
</Group>
<Text size="sm">
{phase === 'load'
? 'Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN.'
: 'Stamps the selected wagons as unloaded and frees them for reuse.'}
</Text>
{pickedPending.length < pending.length ? (
<Text size="xs" c="dimmed">
{pending.length - pickedPending.length} wagon
{pending.length - pickedPending.length === 1 ? '' : 's'} left un
{phase === 'load' ? 'loaded' : 'unloaded'} the train cannot dispatch until they
are {phase === 'load' ? 'loaded' : 'unloaded'} or cancelled.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmOpen(false)}>
Back
</Button>
<Button color={color} radius="md" leftSection={<Icon size={14} />} onClick={submit}>
{phase === 'load' ? 'Load' : 'Unload'}
</Button>
</Group>
</Stack>
</Paper>
) : null}
{phase === 'load' && pending.length > 0 ? (
<Alert color="gray" variant="light" icon={<Info size={14} />} p="xs">
<Text size="xs">
A wagon that will not ride (cancel with fee / EDR fault) and direct truck-to-train
loading are decided on the train schedule workspace, not here.
</Text>
</Alert>
) : null}
</Stack>
</Modal>
);
}

View File

@@ -38,3 +38,5 @@ export { AccrualDashboard } from './AccrualDashboard';
export { DwellAgingCard } from './DwellAgingCard';
export { CycleTimeCard } from './CycleTimeCard';
export { GateThroughputCard } from './GateThroughputCard';
export { TrainLoadingWorkspace } from './TrainLoadingWorkspace';
export { TrainWagonLoadModal } from './TrainWagonLoadModal';

View File

@@ -226,6 +226,7 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
WAGONS_EXPORT: (id: string) => `/bookings/${id}/wagons/export`,
EXPORT_HANDOVER_MODE: (id: string) =>
`/bookings/${id}/export-handover-mode`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,
@@ -536,6 +537,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
SCHEDULE_WAGONS_EXPORT: (id: string) =>
`/train-scheduling/schedules/${id}/wagons/export`,
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
MARSHALLING_STOPS: (id: string) =>

View File

@@ -44,6 +44,18 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
booking.serviceType?.name ??
booking.serviceType?.code,
trainScheduleId: booking.trainScheduleId ?? null,
// List rows carry the flat departure date; detail responses carry the fuller
// summary object instead — fall back to it so a row mapped from either shape
// shows the same date.
trainScheduleDepartureDate:
booking.trainScheduleDepartureDate ??
booking.trainScheduleSummary?.scheduledDepartureDate ??
null,
trainScheduleReference:
booking.trainScheduleReference ??
booking.trainScheduleSummary?.reference ??
booking.trainScheduleSummary?.trainNumber ??
null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,

View File

@@ -16,6 +16,7 @@ import {
Receipt,
RefreshCw,
Ship,
Train,
Truck,
Wallet,
Weight,
@@ -63,6 +64,7 @@ import {
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
BookingWagonsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -219,9 +221,11 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
: requestedTab === "wagons"
? "wagons"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -522,6 +526,9 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
<Tabs.Tab value="wagons" leftSection={<Train size={16} />}>
Wagons
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
@@ -549,6 +556,12 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
<Tabs.Panel value="wagons">
<BookingWagonsPanel
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />

View File

@@ -13,6 +13,7 @@ import {
Plus,
RefreshCw,
Ship,
Train,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -609,7 +610,7 @@ export default function BookingRequestsPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => <span className={bookingTable.headerCell}>Requested</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
@@ -617,6 +618,50 @@ export default function BookingRequestsPage() {
</span>
),
},
{
// The date of the train the booking is actually allocated to. Empty until
// allocation, which is why it is separate from the requested date above —
// the two differ whenever staff move a booking to another day.
id: "scheduledDate",
header: () => (
<span className={bookingTable.headerCell}>Scheduled date</span>
),
cell: ({ row }) => {
const b = row.original;
if (!b.trainScheduleDepartureDate) {
return (
<span className="text-sm text-muted-foreground">
Not scheduled
</span>
);
}
const movedFromRequest =
b.scheduledDate &&
new Date(b.trainScheduleDepartureDate).toDateString() !==
new Date(b.scheduledDate).toDateString();
return (
<div className="space-y-0.5 py-1">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
<Train className="size-3.5 text-muted-foreground" />
{formatDate(b.trainScheduleDepartureDate)}
</span>
{b.trainScheduleReference ? (
<p className="truncate text-xs text-muted-foreground">
{b.trainScheduleReference}
</p>
) : null}
{movedFromRequest ? (
<Badge
variant="outline"
className="h-4 px-1 text-[9px] font-medium"
>
Date changed
</Badge>
) : null}
</div>
);
},
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,

View File

@@ -58,6 +58,12 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/auth/http";
import {
RebookWagonCancellationModal,
canRebookWagonCancellations,
type WagonCancellation,
} from "@/components/bookings/wagon-cancellation";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
@@ -214,6 +220,7 @@ export default function ContractClearanceListPage() {
const canCreateBooking =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const canRebookCredit = canRebookWagonCancellations(user);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
@@ -234,6 +241,21 @@ export default function ContractClearanceListPage() {
refetch,
} = useBookingEtClearanceQueue(true);
// Credit rebook opens the shared modal, which needs the full cancellation
// row — the queue only carries its id, so fetch it on demand.
const [creditRebook, setCreditRebook] = useState<WagonCancellation | null>(
null,
);
const openCreditRebook = useCallback(async (row: ShipmentBookingRow) => {
const res = await api.get<
{ items?: WagonCancellation[] } | WagonCancellation[]
>(`/bookings/${row.id}/wagon-cancellations`);
const body = res.data;
const list = Array.isArray(body) ? body : (body?.items ?? []);
const match = list.find((c) => c.id === row.rebookableCancellationId);
if (match) setCreditRebook(match);
}, []);
// Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so
// the queue shows what each shipment was requested for.
@@ -272,6 +294,7 @@ export default function ContractClearanceListPage() {
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
rebookableCancellationId: b.rebookableCancellationId ?? null,
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
@@ -602,12 +625,14 @@ export default function ContractClearanceListPage() {
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
canRebookCredit={canRebookCredit}
onOpen={openBooking}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebookCredit={openCreditRebook}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh instance would force the
@@ -623,6 +648,15 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
<RebookWagonCancellationModal
cancellation={creditRebook}
onClose={() => setCreditRebook(null)}
onRebooked={() => {
setCreditRebook(null);
// The credit is spent and a new booking exists — both change the queue.
void refetch();
}}
/>
</PageContainer>
);
}
@@ -650,6 +684,8 @@ interface ShipmentBookingRow {
createdAt: string | null;
/** true once GL has actually created (completed) the booking. */
bookingCreated: boolean;
/** Unspent wagon-cancellation credit on this booking, if any. */
rebookableCancellationId: string | null;
}
type PaginationState = ReturnType<typeof usePagination>["pagination"];
@@ -666,9 +702,11 @@ function ShipmentBookingsTable({
hasFilters,
onClearFilters,
canCreateBooking,
canRebookCredit,
onOpen,
onCreateBooking,
onRebook,
onRebookCredit,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -681,9 +719,11 @@ function ShipmentBookingsTable({
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
canRebookCredit: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onRebookCredit: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -701,6 +741,14 @@ function ShipmentBookingsTable({
r.customs &&
r.status === "EXPIRED";
// A cancelled booking whose wagon-cancellation credit is paid for and unspent.
// Redeeming it is a different action from re-completing an expired booking —
// it opens the credit rebook modal rather than the completion form. Gated on
// the rebook permission (not booking-creation) so the button matches exactly
// who the API lets through.
const hasRebookableCredit = (r: ShipmentBookingRow) =>
canRebookCredit && Boolean(r.rebookableCancellationId);
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -840,6 +888,7 @@ function ShipmentBookingsTable({
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
const creditRebookable = hasRebookableCredit(r);
return (
<Group
justify="flex-end"
@@ -870,6 +919,17 @@ function ShipmentBookingsTable({
Rebook
</Button>
) : null}
{creditRebookable ? (
<Button
size="compact-sm"
color="teal"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook credit
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -904,6 +964,14 @@ function ShipmentBookingsTable({
Rebook (GL)
</Menu.Item>
) : null}
{creditRebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook cancellation credit
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -1226,7 +1226,9 @@ const LastMilePage = () => {
// (same as "Mark In Transit") alongside the warehouse exit-weighing flow.
const handleTruckLeaving = (record: LastMileRecord) => {
openTruckArrival(record);
if (record.status === "READY_TO_TRANSIT") {
// Legs recorded before the advance gate can still sit at READY_TO_TRANSIT
// with the advance unpaid; the API would refuse the hop, so don't fire it.
if (record.status === "READY_TO_TRANSIT" && !record.advanceOutstanding) {
updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } });
}
};
@@ -1414,10 +1416,17 @@ const LastMilePage = () => {
const hasDistance = row.original.exactKm != null;
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
// Mirrors the server's advance gate: nothing becomes dispatchable and
// nothing moves until the approved advance is paid. Delivery (the
// IN_TRANSIT step) is not gated, so only the two transit hops are.
const advanceBlocked =
Boolean(row.original.advanceOutstanding) &&
(nextStatus === "READY_TO_TRANSIT" || nextStatus === "IN_TRANSIT");
const canAdvance =
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
!advanceBlocked &&
(status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance));
// Assign stays active until the whole load has trucks: container
// bookings until every container is on a truck; bulk until the
// tonnage is drawn down (trucks depart one by one). Already-departed
@@ -1470,9 +1479,11 @@ const LastMilePage = () => {
disabled={!nextStatus || !canAdvance}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
{advanceBlocked
? "Awaiting advance payment"
: nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item

View File

@@ -1,13 +1,10 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
@@ -27,8 +24,24 @@ const FIELD_LABELS: Record<string, string> = {
cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
minKm: "From km",
maxKm: "To km",
baseLiters: "Base liters",
rateType: "Rate type",
};
/**
* A key the backend diffed but the UI has no label for still names a real
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
*/
const labelFor = (field: string): string =>
FIELD_LABELS[field] ??
field
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.replace(/\bId\b/, "")
.trim();
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
@@ -43,13 +56,16 @@ const fmtValue = (
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
// "Not set" reads as a real before-state; a bare em dash on both sides of the
// arrow made a newly-set field look like no change at all.
if (value === null || value === undefined || value === "") return "Not set";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
// a pair of uuids. Covers yards, cargo types, container types and lines.
if (field.endsWith("Id")) {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " ");
@@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => {
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
/**
* Every change in the request, as readable before→after pairs. The queue must
* be scannable without expanding: a cargo or direction change is just as much
* the point as a repricing, so it gets the same one-line treatment as the rate.
*/
const summaryRows = (
r: RateChangeRequest,
labels?: Record<string, string>,
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
const currency = String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
);
// Rate first — it is what most changes are about — then the rest in a stable
// order so the same edit always reads the same way.
const fields = Object.keys(r.payload).sort((a, b) =>
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
);
return fields.map((field) => ({
field,
label: labelFor(field),
before: fmtValue(field, r.previousValues[field], labels),
after: fmtValue(field, r.payload[field], labels),
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
}));
};
type Decide = UseMutationResult<
@@ -87,8 +123,9 @@ interface RateApprovalsSectionProps {
canDecide: boolean;
approve: Decide;
reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
/** id → label for every reference a diff can name (yards, cargo/container
* types, shipping lines), so a change reads as names, not UUIDs. */
refLabels?: Record<string, string>;
}
/**
@@ -101,11 +138,8 @@ const RateApprovalsSection = ({
canDecide,
approve,
reject,
yardLabels,
refLabels,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
@@ -125,9 +159,8 @@ const RateApprovalsSection = ({
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
const rows = summaryRows(r, refLabels);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
@@ -145,39 +178,26 @@ const RateApprovalsSection = ({
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
{rows.map((row) => (
<Group key={row.field} gap={6} wrap="wrap" align="center">
<Text size="xs" c="dimmed">
{row.label}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
{row.before}
</Text>
<ArrowRight size={13} />
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
{row.after}
{row.suffix}
</Text>
</Group>
) : null}
))}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
</Stack>
{canDecide ? (
@@ -190,7 +210,7 @@ const RateApprovalsSection = ({
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
reject.mutate({ id: r.id })
}
>
Reject
@@ -202,7 +222,7 @@ const RateApprovalsSection = ({
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
approve.mutate({ id: r.id })
}
>
Approve &amp; apply
@@ -217,38 +237,6 @@ const RateApprovalsSection = ({
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field], yardLabels)}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}

View File

@@ -322,9 +322,22 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
/**
* Every id a rate diff can name, in one map. A pending change that swaps the
* cargo type or the container size stores raw uuids, so without this the
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
*/
const rateRefLabelById = useMemo(
() =>
Object.fromEntries(
[
...(yardOptions ?? []),
...(cargoLeafOptions ?? []),
...(containerTypeOptions ?? []),
...(shippingLineOptions ?? []),
].map((o) => [o.value, o.label]),
),
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
@@ -868,7 +881,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
refLabels={rateRefLabelById}
/>
) : null}

View File

@@ -29,6 +29,7 @@ import {
Merge,
Container as ContainerIcon,
Eye,
FileSpreadsheet,
FileText,
History as HistoryIcon,
LayoutGrid,
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type {
ContainerPlacement,
EligibleContainerBooking,
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [exportingWagons, setExportingWagons] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
);
const marshallingStops = marshallingStopsQuery.data ?? [];
/** Wagon list (one row per container) as an .xlsx download. */
const handleExportWagons = useCallback(async () => {
if (!scheduleId) return;
setExportingWagons(true);
try {
const blob =
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason rides inside the Blob, so the sync
// decoder would surface only "Request failed with status code 400".
toast({
title: await extractDownloadErrorMessage(error),
variant: "destructive",
});
} finally {
setExportingWagons(false);
}
}, [scheduleId, schedule?.reference, toast]);
useEffect(() => {
const operation = gatepassQuery.data;
if (!operation) return;
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
Load Empty Container
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="light"
color="edr-green"
size="compact-sm"
leftSection={<FileSpreadsheet size={14} />}
loading={exportingWagons}
onClick={() => void handleExportWagons()}
>
Export wagons
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"

View File

@@ -0,0 +1,50 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { Download } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export interface SectionExportButtonProps {
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
label: string;
/** Runs the download; false means there was nothing to write. */
onExport: () => boolean;
disabled?: boolean;
}
/**
* Excel download for one section of the wagon performance report. Sits in the
* section's own header, so what it exports is unambiguous — the block it is
* attached to, exactly as filtered on screen.
*/
export function SectionExportButton({
label,
onExport,
disabled,
}: SectionExportButtonProps) {
const { toast } = useToast();
return (
<Tooltip label={`Download ${label} as Excel`}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={`Download ${label} as Excel`}
disabled={disabled}
onClick={(e) => {
// The row underneath may navigate; a download must not trigger it.
e.stopPropagation();
const wrote = onExport();
if (!wrote) {
toast({
title: "Nothing to export",
description: `There are no ${label} rows to download yet.`,
});
}
}}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,80 @@
import * as XLSX from "xlsx";
/**
* Excel download for one section of the wagon performance report.
*
* Each section on the page exports exactly what is on screen — the same rows,
* in the same order, honouring the same filters and date window — so a figure
* in the spreadsheet always reconciles with the figure the CEO just read.
*
* Built client-side from data already in the browser: the report holds the
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
* re-fetch and no server round-trip.
*/
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
export interface SheetSpec {
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
name: string;
rows: Array<Record<string, string | number | null>>;
}
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
const safeSheetName = (name: string): string =>
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
/** Widen each column to its longest cell, so nothing opens as ####. */
function fitColumns(
rows: Array<Record<string, unknown>>,
): Array<{ wch: number }> {
const headers = Object.keys(rows[0] ?? {});
return headers.map((h) => {
const longest = rows.reduce((max, row) => {
const cell = row[h];
const len = cell == null ? 0 : String(cell).length;
return len > max ? len : max;
}, h.length);
// Cap the width so one long note cannot push a column off the screen.
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
});
}
/** Timestamp suffix so repeated downloads don't overwrite each other. */
const stamp = (): string => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
};
/**
* Download one or more sheets as a single .xlsx.
*
* `filenameBase` gets the timestamp and extension appended. Sheets with no
* rows are skipped; if that leaves nothing, the download is skipped entirely
* and the function returns false so the caller can say so.
*/
export function downloadSheets(
filenameBase: string,
sheets: SheetSpec[],
): boolean {
const populated = sheets.filter((s) => s.rows.length > 0);
if (!populated.length) return false;
const workbook = XLSX.utils.book_new();
for (const spec of populated) {
const sheet = XLSX.utils.json_to_sheet(spec.rows);
sheet["!cols"] = fitColumns(spec.rows);
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
}
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
return true;
}
/** Single-sheet convenience wrapper — the shape most sections need. */
export function downloadSheet(
filenameBase: string,
sheetName: string,
rows: Array<Record<string, string | number | null>>,
): boolean {
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
}

View File

@@ -0,0 +1,237 @@
/**
* Derived wagon performance figures for the CEO's wagon report.
*
* Nothing here is stored: every number is computed in the browser from the
* ledgers the API already returns — `wagon_movements` (relocations),
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
* Keeping the derivation in one place means the report and the wagon record
* can never disagree about what "idle" or "utilisation" means.
*
* This report is READ-ONLY and lives beside the Overview dashboard. It does
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
*/
import { Freight } from "@edr/types";
import type {
Wagon,
WagonMovementRecord,
WagonStatusLog,
} from "@/services/wagon.service";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days past which a parked wagon is treated as stranded. */
export const IDLE_THRESHOLD_DAYS = 21;
/** Days off the roster past which a repair is treated as overdue. */
export const DOWN_THRESHOLD_DAYS = 30;
/** Statuses that take a wagon off the earning roster. */
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
Freight.WagonStatus.Maintenance,
Freight.WagonStatus.Detained,
Freight.WagonStatus.OutOfService,
];
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
OFF_ROSTER_STATUSES.includes(status);
/** Whole days between `iso` and now; null when the timestamp is missing. */
export function daysSince(iso: string | null | undefined): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
}
/** Fractional days between two timestamps; `to` null means "still open". */
export function daysBetween(
from: string | null | undefined,
to: string | null | undefined,
): number | null {
if (!from) return null;
const a = new Date(from).getTime();
if (Number.isNaN(a)) return null;
const b = to ? new Date(to).getTime() : Date.now();
if (Number.isNaN(b)) return null;
return Math.max(0, (b - a) / DAY_MS);
}
export interface WagonPerformance {
/** Days since the wagon last arrived anywhere — the idle clock. */
idleDays: number | null;
/** Days in the current off-roster spell; null while in service. */
downDays: number | null;
loads: number;
moves: number;
emptyMoves: number;
manualMoves: number;
/** Share of moves that carried cargo, 0100; null when nothing moved. */
loadedShare: number | null;
lastMovement: WagonMovementRecord | null;
/** Off-roster spells overlapping the window. */
spells: number;
/** Days off roster inside the window. */
downDaysInWindow: number;
/** Share of the window spent on the roster, 0100. */
availability: number;
}
/**
* Roll one wagon's ledgers up into the figures the report shows.
*
* `windowDays` bounds loads, moves and downtime. Idle days and the current
* down spell are "how long has this been true right now" — never windowed.
*/
export function computeWagonPerformance(
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
movements: WagonMovementRecord[],
statusLogs: WagonStatusLog[],
windowDays: number,
): WagonPerformance {
const since = Date.now() - windowDays * DAY_MS;
// Movements arrive newest-first from the API; don't rely on it.
const ordered = [...movements].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
const lastMovement = ordered[0] ?? null;
const inWindow = ordered.filter((m) => {
const t = new Date(m.occurredAt).getTime();
return !Number.isNaN(t) && t >= since;
});
const loads = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Loaded,
).length;
const emptyMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
).length;
const manualMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Manual,
).length;
const moves = inWindow.length;
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
// Newest first, so a flip's "until" is the log entry before it in the array.
const logs = [...statusLogs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
let downDays: number | null = null;
if (isOffRoster(wagon.status)) {
const entered = logs.find((l) => l.toStatus === wagon.status);
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
}
// Downtime inside the window: walk each off-roster entry to the flip that
// ended it, clamping both ends to the window.
let downDaysInWindow = 0;
let spells = 0;
logs.forEach((log, i) => {
if (!isOffRoster(log.toStatus)) return;
const start = new Date(log.createdAt).getTime();
if (Number.isNaN(start)) return;
const closed = logs[i - 1];
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
const from = Math.max(start, since);
const to = Math.min(end, Date.now());
if (to <= from) return;
downDaysInWindow += (to - from) / DAY_MS;
spells += 1;
});
const availability =
windowDays > 0
? Math.max(
0,
Math.min(
100,
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
),
)
: 100;
return {
idleDays,
downDays,
loads,
moves,
emptyMoves,
manualMoves,
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
lastMovement,
spells,
downDaysInWindow: Math.round(downDaysInWindow),
availability,
};
}
/** Mantine colour per wagon status. */
export function statusColor(status: Freight.WagonStatus): string {
switch (status) {
case Freight.WagonStatus.Available:
return "edr-green";
case Freight.WagonStatus.Assigned:
case Freight.WagonStatus.ImportReady:
return "blue";
case Freight.WagonStatus.ExportReady:
return "teal";
case Freight.WagonStatus.Maintenance:
return "yellow";
case Freight.WagonStatus.Detained:
return "red";
case Freight.WagonStatus.OutOfService:
default:
return "gray";
}
}
/** Mantine colour per movement kind. */
export function movementKindColor(kind: Freight.WagonMovementKind): string {
switch (kind) {
case Freight.WagonMovementKind.Loaded:
return "edr-green";
case Freight.WagonMovementKind.EmptyReposition:
return "teal";
case Freight.WagonMovementKind.Maintenance:
return "yellow";
case Freight.WagonMovementKind.Manual:
default:
return "gray";
}
}
/** Mantine colour per history-event category. */
export function eventCategoryColor(
category: Freight.WagonEventCategory,
): string {
switch (category) {
case Freight.WagonEventCategory.Yard:
return "yellow";
case Freight.WagonEventCategory.Train:
return "blue";
case Freight.WagonEventCategory.Schedule:
return "indigo";
case Freight.WagonEventCategory.Cargo:
return "edr-green";
case Freight.WagonEventCategory.Status:
return "orange";
case Freight.WagonEventCategory.Lifecycle:
default:
return "gray";
}
}
/** Idle banding shared by the table and the distribution chart. */
export function idleBand(
idleDays: number | null,
): "ok" | "watch" | "stranded" | "unknown" {
if (idleDays == null) return "unknown";
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
return "ok";
}

View File

@@ -810,6 +810,14 @@ export const bookingsService = {
return ensurePdfBlob(response.data as Blob);
},
/** The Wagons tab's Excel export — customer name plus one row per wagon. */
downloadWagonsWorkbook: async (id: string): Promise<Blob> => {
const response = await client.get(B.WAGONS_EXPORT(id), {
responseType: "blob",
});
return response.data as Blob;
},
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
return (unwrap(response.data) ?? []) as BookingDetail[];

View File

@@ -84,6 +84,12 @@ export interface LastMileRecord {
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
/**
* The approved last-mile advance has not been paid yet. While true the API
* refuses to make this leg dispatchable or move it, so the UI must not offer
* those transitions.
*/
advanceOutstanding?: boolean;
/** Truck-detention clock: vehicle arrival + delivery/return times. */
arrivedAt?: string | null;
deliveredAt?: string | null;

View File

@@ -756,6 +756,15 @@ export const trainSchedulingService = {
return response.data;
},
/** The schedule detail page's wagon-list Excel export. */
downloadScheduleWagonsWorkbook: async (scheduleId: string): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_WAGONS_EXPORT(scheduleId),
{ responseType: "blob" },
);
return response.data as Blob;
},
downloadIntercityMarshallingDocument: async (
scheduleId: string,
): Promise<Blob> => {

View File

@@ -29,6 +29,12 @@ export interface Wagon {
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
/** Newest wagon_movements arrival — the idle clock's start (list endpoint only). */
lastMovedAt?: string | null;
/** Loaded / empty / total moves inside `statsWindowDays` (list endpoint only). */
loadsInWindow?: number;
movesInWindow?: number;
emptyMovesInWindow?: number;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
@@ -55,6 +61,8 @@ export interface WagonListFilters {
* the latest status-log flip to MAINTENANCE, not a stored column. */
maintenanceFrom?: string;
maintenanceTo?: string;
/** Window (days) the per-row load/move counts cover. Does not filter rows. */
statsWindowDays?: number;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
@@ -73,6 +81,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
if (filters.statsWindowDays)
params.set('statsWindowDays', String(filters.statsWindowDays));
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
@@ -101,6 +111,8 @@ export interface WagonMovementRecord {
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
/** The booking's human reference, joined at read time. Null when unloaded. */
bookingReference?: string | null;
}
/** One row of the wagon status audit trail. Returned newest first by the API. */

View File

@@ -235,9 +235,15 @@ export const warehouseService = {
},
// ── Load to Train ─────────────────────────────────────────────────────────
/** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */
getLoadableTrains: async (): Promise<LoadableTrain[]> => {
const { data } = await apiClient.get('/warehouse-inventory/loadable-trains');
/**
* EXPORT trains with inventory waiting to be loaded. Pre-dispatch only by
* default; `includeDispatched` adds rolling trains still boarding cargo at
* corridor yards (what the train-centric loading workspace lists).
*/
getLoadableTrains: async (opts: { includeDispatched?: boolean } = {}): Promise<LoadableTrain[]> => {
const { data } = await apiClient.get('/warehouse-inventory/loadable-trains', {
params: opts.includeDispatched ? { includeDispatched: 'true' } : undefined,
});
return data?.data ?? data ?? [];
},

View File

@@ -216,6 +216,14 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/**
* The allocated train's departure date, attached by the LIST endpoint (the
* detail endpoint carries the fuller `trainScheduleSummary` instead). This is
* the operational date, as opposed to the customer-requested `scheduledDate`.
*/
trainScheduleDepartureDate?: string | null;
/** The allocated train's reference (S-YYYY-NNNNN) or train number. */
trainScheduleReference?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
@@ -244,6 +252,12 @@ export interface BookingDetail {
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */
hasDocumentsAwaitingReview?: boolean;
/**
* ET clearance queue: id of an unspent wagon-cancellation credit on this
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
* none — GL rebooks the credit straight from the queue row.
*/
rebookableCancellationId?: string | null;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
@@ -304,6 +318,10 @@ export interface BookingListRow {
schedulingStatus?: string;
serviceTypeLabel?: string;
trainScheduleId?: string | null;
/** Departure date of the train this booking is allocated to; null until scheduled. */
trainScheduleDepartureDate?: string | null;
/** Reference of the train this booking is allocated to. */
trainScheduleReference?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;

View File

@@ -1258,10 +1258,24 @@ export interface BookingWagonRow {
allocatedWeightTons: number | string | null;
loadType: string | null;
status: string;
/** Numeric columns arrive as strings from pg — parse before arithmetic. */
tareWeightTons?: number | string | null;
capacityTons?: number | string | null;
lengthMeters?: number | string | null;
/** The train this wagon rides on, and where it runs. */
trainNumber?: string | null;
departureAt?: string | null;
originStation?: string | null;
destinationStation?: string | null;
/** Set only when the wagon carries bulk rather than containers. */
bulkCargoDescription?: string | null;
bulkQuantity?: number | string | null;
containers: Array<{
containerNumber: string | null;
sizeFt: number | null;
grossWeightTons: number | string | null;
sealNumber?: string | null;
positionOnWagon?: number | null;
}>;
}

View File

@@ -0,0 +1,65 @@
-- Fare-class upgrade (policy US-17). One policy row per fare class — fare classes map 1:1 onto
-- coach types — plus a per-request table recording the frozen quote.
--
-- Structure only, and additive/idempotent. Policy data lives in prisma/seed.ts.
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."UpgradePolicy" (
"id" TEXT NOT NULL,
"coachTypeId" TEXT NOT NULL,
"rank" INTEGER NOT NULL DEFAULT 0,
"feePercent" INTEGER NOT NULL DEFAULT 0,
"feeMinMinor" INTEGER NOT NULL DEFAULT 0,
"feeWaived" BOOLEAN NOT NULL DEFAULT false,
"isUpgradable" BOOLEAN NOT NULL DEFAULT true,
"isTargetable" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UpgradePolicy_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "UpgradePolicy_coachTypeId_key" ON "passenger"."UpgradePolicy"("coachTypeId");
DO $$ BEGIN
ALTER TABLE "passenger"."UpgradePolicy"
ADD CONSTRAINT "UpgradePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId")
REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- CreateTable
CREATE TABLE IF NOT EXISTS "passenger"."BookingUpgrade" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"leg" INTEGER NOT NULL DEFAULT 1,
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
"requestedBy" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"holdId" TEXT,
"oldFareMinor" INTEGER NOT NULL,
"newFareMinor" INTEGER NOT NULL,
"fareDifferenceMinor" INTEGER NOT NULL,
"feeMinor" INTEGER NOT NULL,
"amountDueMinor" INTEGER NOT NULL,
"supplementaryChargeId" TEXT,
"expiresAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingUpgrade_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BookingUpgrade_supplementaryChargeId_key" ON "passenger"."BookingUpgrade"("supplementaryChargeId");
CREATE INDEX IF NOT EXISTS "BookingUpgrade_bookingId_status_idx" ON "passenger"."BookingUpgrade"("bookingId", "status");
DO $$ BEGIN
ALTER TABLE "passenger"."BookingUpgrade"
ADD CONSTRAINT "BookingUpgrade_bookingId_fkey" FOREIGN KEY ("bookingId")
REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- No data seeding here on purpose. This migration creates structure only; the ladder itself is
-- business policy and is seeded separately by `seedUpgradePolicies` in prisma/seed.ts
-- (`pnpm prisma:seed`), so a production deploy never silently writes fare rules nobody approved.

View File

@@ -81,6 +81,7 @@ model CoachType {
coaches Coach[]
seatClasses SeatClass[]
reschedulePolicy ReschedulePolicy?
upgradePolicy UpgradePolicy?
@@schema("passenger")
}
@@ -570,6 +571,7 @@ model Booking {
agentBooking AgentBooking?
modifications BookingModification[]
reschedules BookingReschedule[]
upgrades BookingUpgrade[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
@@ -1207,6 +1209,64 @@ model AgentCommission {
@@schema("passenger")
}
/// Fare-class upgrade rule, one row per coach type (policy US-17). A coach type with no row here
/// can be neither upgraded from nor to — the same "no policy = not allowed" semantics
/// ReschedulePolicy uses. Edited in backoffice Master Data → Upgrade Policies.
model UpgradePolicy {
id String @id @default(uuid())
coachTypeId String @unique
/// Position on the ladder — an upgrade requires target.rank > source.rank. An explicit column
/// rather than a price comparison: SeatClass.baseFareMinor is a per-km tariff, while the fare
/// actually charged resolves through SegmentFareRule/FareRule first, so on some segments the
/// price order differs from the class order. Which class is "higher" is a business decision
/// and must not flip because someone edited a tariff.
rank Int @default(0)
feePercent Int @default(0) // % of the passenger's original fare
feeMinMinor Int @default(0) // fee floor, ETB minor units
feeWaived Boolean @default(false)
isUpgradable Boolean @default(true) // passengers may leave this class
isTargetable Boolean @default(true) // passengers may arrive in this class
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
@@schema("passenger")
}
/// One fare-class upgrade request for one leg. Same lifecycle as BookingReschedule
/// (PENDING_PAYMENT → APPLIED | EXPIRED) but the schedule never changes — only the seats, and
/// only for the passengers named in `items`.
model BookingUpgrade {
id String @id @default(uuid())
bookingId String
leg Int @default(1)
status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED
requestedBy String
scheduleId String // unchanged by the upgrade; recorded so the audit row reads standalone
/// Frozen per-passenger quote, keyed on bookingSeatId — NOT array position. Only some
/// passengers move, so a positional pairing (as BookingReschedule uses) would be fragile.
/// Each element: { bookingSeatId, passengerName, passengerCategory,
/// oldSeatId, oldSeatLabel, oldCoachTypeId, oldSeatClassId, oldFareMinor,
/// newSeatId, newSeatLabel, newCoachTypeId, newSeatClassId, newFareMinor,
/// feeMinor, fareDifferenceMinor }
items Json
holdId String?
oldFareMinor Int
newFareMinor Int
fareDifferenceMinor Int
feeMinor Int
amountDueMinor Int
supplementaryChargeId String? @unique
expiresAt DateTime?
appliedAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId, status])
@@schema("passenger")
}
/// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto
/// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy
/// doc; edited in backoffice Settings → Reschedule Policy.

View File

@@ -657,7 +657,70 @@ async function seedNotificationTemplates() {
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
{ id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' },
// Rich bodies mirroring booking.created: these are delivered by SMS *and* email, so the
// wording has to stand alone in a text message. Channel is 'SMS,EMAIL' — the applied/expired
// handlers deliver directly, but the field keeps the row honest about where it goes.
{ id: uuidv4(), code: 'booking.rescheduled', channel: 'SMS,EMAIL', subject: 'Booking Rescheduled', bodyTemplate: `Dear {{passengerName}},
Your {{leg}} journey on booking ({{bookingRef}}) has been rescheduled.
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
{{previousLine}}Travel Date: {{travelDate}}
Departure: {{departureTime}}
Arrival: {{arrivalTime}}
Paid: {{amountPaid}} {{currency}} (change fee: {{feeAmount}} {{currency}})
New tickets have been issued.
View your booking: {{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.upgraded', channel: 'SMS,EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: `Dear {{passengerName}},
Your booking ({{bookingRef}}) has been upgraded on the {{leg}} journey.
Route: {{origin}} → {{destination}}
{{changeLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
Paid: {{amountPaid}} {{currency}}
New tickets have been issued.
View your booking: {{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.reschedule.expired', channel: 'SMS,EMAIL', subject: 'Reschedule Request Expired', bodyTemplate: `Dear {{passengerName}},
Your reschedule request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied.
The seat that was being held for it has been released. Your original booking, seats and travel date are unchanged:
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
You can start a new reschedule any time before check-in closes:
{{detailLink}}
Thank you for choosing EDR.` },
{ id: uuidv4(), code: 'booking.upgrade.expired', channel: 'SMS,EMAIL', subject: 'Upgrade Request Expired', bodyTemplate: `Dear {{passengerName}},
Your upgrade request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied.
The seat that was being held for it has been released. Your original booking and seats are unchanged:
Route: {{origin}} → {{destination}}
{{trainSeatLines}}
Travel Date: {{travelDate}}
Departure: {{departureTime}}
You can start a new upgrade any time before check-in closes:
{{detailLink}}
Thank you for choosing EDR.` },
// Templates below are not wired to handlers yet (Phase 2 — full event coverage).
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },

View File

@@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
import { EOtpType } from "@tria-plc/iamapi-common";
import { RescheduleModule } from './modules/reschedule/reschedule.module';
import { UpgradeModule } from './modules/upgrade/upgrade.module';
@Module({
imports: [
@@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module';
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
UpgradeModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = {
Booking: 'Booking',
BookingReschedule: 'BookingReschedule',
ReschedulePolicy: 'ReschedulePolicy',
BookingUpgrade: 'BookingUpgrade',
UpgradePolicy: 'UpgradePolicy',
} as const;
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];

View File

@@ -0,0 +1,144 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import { MeLikeUser } from '../passenger-permission.util';
import { normalizePhone, samePhone } from './phone.utils';
/**
* Shared by every flow that lets a passenger change a confirmed booking — reschedule today,
* fare-class upgrade next. These were private to RescheduleService; they live here so the two
* features cannot drift apart on who is allowed to act or how a seat is priced.
*
* Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything
* made injectable here would drag request scope into whatever consumes it.
*/
export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string };
/**
* The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an
* empty string, so `iam.users` is the source of truth — and reading it live also means a user
* who changed their number does not have to sign out before the new one counts.
*/
export async function resolveUserPhone(
prisma: PrismaService,
iamUserId: string,
user: ActingUser,
): Promise<string | null> {
const fromSession = normalizePhone(user.phoneNumber);
if (fromSession) return fromSession;
const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>`
SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1
`;
return normalizePhone(rows[0]?.phone_number);
}
/**
* Loads a booking only for the person who made it, proven by their account's phone number
* matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a
* passenger travelling on someone else's booking cannot change it.
*
* There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on
* the stationMaster preset) but is not honoured, so a station master cannot act on a customer's
* behalf yet.
*
* `action` only shapes the error message ("reschedule it" / "upgrade it").
*/
export async function loadOwnedBooking<T extends Prisma.BookingInclude>(
prisma: PrismaService,
bookingRef: string,
user: ActingUser,
include: T,
action = 'change it',
) {
const booking = await prisma.booking.findUnique({ where: { bookingRef }, include });
if (!booking) throw new NotFoundException('Booking not found');
const iamUserId = user.id ?? user.sub;
if (!iamUserId) throw new ForbiddenException();
const b = booking as any;
if (b.contactPhone) {
const callerPhone = await resolveUserPhone(prisma, iamUserId, user);
if (samePhone(callerPhone, b.contactPhone)) return booking;
throw new ForbiddenException(
`Only the person who made this booking can ${action}. Sign in with the phone number used to book.`,
);
}
// A small tail of bookings carry no contactPhone at all, so there is nothing to match against.
// Fall back to the account link rather than locking their owner out entirely.
const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking');
return booking;
}
/**
* Coaches nobody buys a seat in, so they can never carry a fare-class policy.
*
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
* dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`).
*/
export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
export const NOT_A_FARE_CLASS = {
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
{ type: { contains: term, mode: 'insensitive' as const } },
{ code: { contains: term, mode: 'insensitive' as const } },
]),
};
/** True when this coach type is a dining/baggage coach rather than a sellable fare class. */
export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean {
const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase();
return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t));
}
/**
* Nationality is not stored on the booking, so the display currency is the proxy the search and
* fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must
* use the same proxy or an upgrade would be priced on a different tariff than the original sale.
*/
export function resolveNationalityProxy(displayCurrency?: string | null): {
nationalityType: 'LOCAL' | 'INTERNATIONAL';
nationality: string | undefined;
} {
return {
nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL',
nationality:
displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined,
};
}
/**
* Mirrors SearchService's class matching: nationality filter, then bed position.
* `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding.
*/
export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) {
const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType);
const pool = byNat.length ? byNat : classes;
const bed = bedPosition?.toLowerCase() ?? null;
const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed);
return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null;
}
/**
* Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last
* paid seat.
*/
export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] {
const paid = seats.map((s) => s.fareMinor !== 0);
const n = paid.filter(Boolean).length || 1;
const each = Math.floor(total / n);
let remaining = total;
let lastPaid = -1;
const out = seats.map((_, i) => {
if (!paid[i]) return 0;
lastPaid = i;
remaining -= each;
return each;
});
if (lastPaid >= 0) out[lastPaid] += remaining;
return out;
}

View File

@@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7;
export const PAYMENT_SETTLE_MARGIN_SECONDS = 60;
/**
* `windowMinutes` is how long the payer is given, and is configurable per flow
* (`booking_payment_window_minutes`, `reschedule_…`, `upgrade_…` in SystemConfig). It defaults to
* MAX_PAYMENT_HOURS so any caller that does not pass it behaves exactly as before.
*
* The check-in cutoff is still the hard ceiling: a longer window can never let someone pay after
* boarding has closed on their train.
*
* EVERY site that decides whether a booking is still payable — the payment link, the seat hold,
* and the crons that auto-cancel unpaid bookings — must pass the SAME window for a given booking,
* or a cron will cancel a booking whose link still says it is valid.
*/
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
checkinMinutes: number = CUTOFF_MINUTES,
windowMinutes: number = MAX_PAYMENT_HOURS * 60,
): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const maxDeadline = new Date(createdAt.getTime() + windowMinutes * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { AuditModule } from '../../common/audit.module';
@@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
imports: [SystemConfigModule, AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -93,22 +93,105 @@ export class BookingsService {
private readonly paymentsService: PaymentsService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// 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);
/**
* Every Booking-level condition that means "this booking belongs to the person who
* owns `variants`". Shared by findByPhone (public guest retrieval) and
* findByIamUserId (the portal's own history) so the two can never disagree about
* what a phone number owns.
*
* The IAM lookup is catch-and-warn: a phone match is a best-effort widening, and an
* unavailable IAM must not fail the whole listing.
*/
private async buildPhoneOwnershipClauses(
variants: string[],
): Promise<{ clauses: any[]; passengerIds: string[] }> {
if (variants.length === 0) return { clauses: [], passengerIds: [] };
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number, linked through passenger.iamUserId.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
return {
clauses: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(iamPassengerIds.length > 0 ? [{ passengerId: { in: iamPassengerIds } }] : []),
],
passengerIds: iamPassengerIds,
};
}
/**
* The portal's authenticated "My bookings" history (GET /bookings/my).
*
* Returns bookings made **while signed in** (they hang off the Passenger row linked
* to this IAM user) *and* bookings made as a **guest with the same phone number**.
* The second half matters: resolveGuestPassenger (guest-booking.service.ts) creates a
* fresh, unlinked `Passenger` for every guest booking and never looks the phone up, so
* a customer's guest history is scattered across orphan rows that a passengerId-only
* filter cannot see. On the dev database one account had 4 visible bookings out of 30
* carrying its own phone number.
*
* Privacy note: the widened set is exactly what `GET /bookings/by-phone` already
* returns to *anonymous* callers, so showing it to the verified owner of that number
* exposes nothing that was not already public. The phone comes from iam.users, not
* from the request.
*/
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
// 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.
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
const iamRows = await this.dataSource
.query<{ phone_number: string | null }[]>(
`SELECT phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
)
.catch((err: unknown) => {
this.logger.warn(`IAM self phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { phone_number: string | null }[];
});
const variants = normalizePhoneVariants(iamRows[0]?.phone_number ?? '');
const ownership: any[] = [
...(passenger ? [{ passengerId: passenger.id }] : []),
...(await this.buildPhoneOwnershipClauses(variants)).clauses,
];
if (ownership.length === 0) {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
}
return this.findBookingsForOwner({ OR: ownership }, filters);
}
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
return this.findBookingsForOwner({ passengerId }, filters);
}
/**
* One page of a customer's own bookings. `ownerClause` says whose they are (a single
* passengerId, or the OR of every phone-ownership clause) and is ANDed with the
* search / status / scope filters, so none of them can clobber another's `OR`.
*
* `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
@@ -116,40 +199,40 @@ export class BookingsService {
* 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 = {}) {
private async findBookingsForOwner(ownerClause: any, filters: BookingFilters = {}) {
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = { passengerId };
const and: any[] = [ownerClause];
if (search) {
where.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
];
and.push({
OR: [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } },
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
],
});
}
// `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;
and.push({ status });
}
const now = new Date();
let orderBy: any = { createdAt: 'desc' };
if (scope === 'cancelled') {
where.status = { in: CLOSED_BOOKING_STATUSES };
and.push({ 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 },
};
and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } });
and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } });
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
}
const where: any = { AND: and };
const [items, total] = await Promise.all([
this.prisma.booking.findMany({
where,
@@ -227,68 +310,11 @@ export class BookingsService {
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
// that findAll uses for the search field.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
// Same ownership resolution the authenticated history uses, so a customer sees the
// same set here and on "My bookings".
const ownership = await this.buildPhoneOwnershipClauses(variants);
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking).
// This catches cases where contactPhone was null but the phone was still recorded in the profile.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
const travelerPassengerIds = travelerRows.map(r => r.passengerId);
// Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile
// row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
// Merge all passenger IDs from every source
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])];
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
};
const where: any = { OR: ownership.clauses };
if (status) where.status = status;
// PackageBooking is a separate table with its own contactPhone field —
@@ -296,7 +322,7 @@ export class BookingsService {
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;

View File

@@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
import { JourneyDirection } from '../seats/seats.dto';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { randomUUID } from 'crypto';
@@ -90,6 +91,7 @@ export class GuestBookingService {
private readonly logger = new Logger(GuestBookingService.name);
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private seatsService: SeatsService,
@@ -582,7 +584,10 @@ export class GuestBookingService {
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
const payToken = isStaff ? undefined : randomUUID();
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const payTokenExpiresAt = isStaff
? undefined
: computePaymentDeadline(new Date(), schedule.departureAt, undefined, bookingWindowMinutes);
const booking = await this.prisma.booking.create({
data: {

View File

@@ -0,0 +1,141 @@
import { NotificationsService } from './notifications.service';
/**
* Regression cover for the bug these handlers were written to fix: reschedule/upgrade
* notifications resolved an address only through `iam.users`, where `Passenger.iamUserId` is set
* on under 2% of rows, so EMAIL and SMS were silently skipped on virtually every real booking.
* The handlers must fall back to the contact details the booking itself carries.
*/
describe('booking-change notifications', () => {
const BOOKING = {
id: 'bk-1',
bookingRef: 'NFMRR0',
passengerId: 'pax-1',
bookingType: 'ONE_WAY',
contactPhone: '+251923594242',
contactEmail: 'work.abubeker@gmail.com',
originStationId: 'st-a',
destinationStationId: 'st-b',
schedule: {
departureAt: new Date('2026-09-22T09:00:00Z'),
arrivalAt: new Date('2026-09-22T18:00:00Z'),
originStation: { id: 'st-a', name: 'Sebeta' },
destinationStation: { id: 'st-b', name: 'Dire Dawa' },
stopTimes: [],
},
seats: [
{
leg: 1,
passengerName: 'Abubeker Yasin',
seat: { seatNumber: '3', coach: { number: 'VIP-0001', coachType: { name: 'VIP Seat' } } },
},
],
};
const TEMPLATE = {
code: 'booking.upgraded',
subject: 'Fare Class Upgraded',
bodyTemplate:
'Dear {{passengerName}},\n{{bookingRef}} {{origin}} → {{destination}}\n{{changeLines}}\nPaid: {{amountPaid}} {{currency}}\n{{detailLink}}',
active: true,
};
function build(opts: { iamAddress?: string | null; template?: any; booking?: any } = {}) {
const sms = jest.fn().mockResolvedValue({ queued: true });
const email = jest.fn().mockResolvedValue({ queued: true });
const svc: any = Object.create(NotificationsService.prototype);
svc.prisma = {
booking: { findUnique: jest.fn().mockResolvedValue(opts.booking === undefined ? BOOKING : opts.booking) },
notificationTemplate: {
findUnique: jest.fn().mockResolvedValue(opts.template === undefined ? TEMPLATE : opts.template),
},
};
svc.smsClient = { sendSms: sms };
svc.emailClient = { sendEmail: email };
svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
// The live condition: IAM knows nothing about this passenger.
svc.getRecipientAddress = jest.fn().mockResolvedValue(opts.iamAddress ?? null);
svc.createInAppNotification = jest.fn().mockResolvedValue(undefined);
return { svc, sms, email };
}
const upgradePayload = {
booking: { id: 'bk-1' },
upgrade: {
leg: 1,
feeMinor: 0,
fareDifferenceMinor: 70000,
items: [
{ passengerName: 'Abubeker Yasin', oldSeatLabel: 'RS-0002 seat 5', newSeatLabel: 'VIP-0001 seat 3' },
],
},
};
it('sends SMS and email via the booking contacts when IAM resolves nothing', async () => {
const { svc, sms, email } = build();
await svc.onBookingUpgraded(upgradePayload);
expect(sms).toHaveBeenCalledTimes(1);
expect(sms.mock.calls[0][0].to).toBe('+251923594242');
expect(email).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].to).toBe('work.abubeker@gmail.com');
expect(email.mock.calls[0][0].subject).toBe('Fare Class Upgraded');
});
it('renders the old → new seat line and the amount paid', async () => {
const { svc, sms } = build();
await svc.onBookingUpgraded(upgradePayload);
const body = sms.mock.calls[0][0].message;
expect(body).toContain('Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3');
expect(body).toContain('Paid: 700.00 ETB');
expect(body).toContain('Sebeta → Dire Dawa');
expect(body).not.toContain('{{'); // every placeholder interpolated
});
it('prefers the IAM address when there is one', async () => {
const { svc, sms } = build({ iamAddress: '+251900000000' });
await svc.onBookingUpgraded(upgradePayload);
expect(sms.mock.calls[0][0].to).toBe('+251900000000');
});
it('a failing SMS gateway does not suppress the email', async () => {
const { svc, sms, email } = build();
sms.mockRejectedValue(new Error('gateway down'));
await svc.onBookingUpgraded(upgradePayload);
expect(email).toHaveBeenCalledTimes(1);
expect(svc.logger.warn).toHaveBeenCalled();
});
it('sends nothing and does not throw when the booking has no contacts', async () => {
const { svc, sms, email } = build({ booking: { ...BOOKING, contactPhone: null, contactEmail: null } });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(email).not.toHaveBeenCalled();
});
it('a missing template is logged, not thrown', async () => {
const { svc, sms } = build({ template: null });
await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
expect(svc.logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found or inactive'));
});
it('expiry notification sends and never throws', async () => {
const { svc, sms, email } = build({
template: { ...TEMPLATE, code: 'booking.upgrade.expired', subject: 'Upgrade Request Expired' },
});
await svc.onUpgradeExpired({
bookingId: 'bk-1',
request: { leg: 1, amountDueMinor: 70000, items: upgradePayload.upgrade.items },
});
expect(sms).toHaveBeenCalledTimes(1);
expect(email.mock.calls[0][0].subject).toBe('Upgrade Request Expired');
});
it('a booking that vanished is logged, not thrown', async () => {
const { svc, sms } = build({ booking: null });
await expect(svc.onUpgradeExpired({ bookingId: 'gone', request: {} })).resolves.toBeUndefined();
expect(sms).not.toHaveBeenCalled();
});
});

View File

@@ -148,6 +148,180 @@ export class NotificationsService {
});
}
// ── Booking-change notification helpers ──────────────────────────────────
/** Relations the change templates render: station names, coach number and coach-type name. */
private static readonly CHANGE_INCLUDE = {
schedule: {
include: {
originStation: true,
destinationStation: true,
train: true,
stopTimes: { include: { station: true } },
},
},
seats: {
include: { seat: { include: { coach: { include: { coachType: true } } } } },
orderBy: { leg: 'asc' as const },
},
};
private fmtDate(d: any): string {
return d
? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
: 'TBD';
}
private fmtTime(d: any): string {
return d
? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
: 'TBD';
}
/** Minor units → major, 2dp. Charges are raised in ETB, so no conversion applies. */
private fmtMinor(minor: number): string {
return ((minor ?? 0) / 100).toFixed(2);
}
/**
* "Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3", one line per upgraded passenger.
* Labels come off `BookingUpgrade.items`, which snapshots them at quote time — so the message
* still reads correctly even after the seats have moved.
*/
private buildUpgradeChangeLines(items: any[]): string {
return (items ?? [])
.map((i) => {
const who = String(i?.passengerName ?? '').trim();
const from = String(i?.oldSeatLabel ?? '').trim() || 'previous seat';
const to = String(i?.newSeatLabel ?? '').trim() || 'new seat';
return `${who ? `${who}: ` : ''}${from}${to}`;
})
.join('\n');
}
/**
* Delivery addresses for a booking-change message. IAM first so a registered passenger's
* current details win, then the contact the booking itself carries — which is the only address
* a guest booking ever has. Mirrors the `iamPhone ?? contactPhone` fallback that
* `onBookingCreated` and `onPaymentSucceeded` already use.
*/
private async resolveDeliveryContacts(
booking: any,
passengerId: string | null,
): Promise<{ phone: string | null; email: string | null }> {
const iamPhone = passengerId
? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null)
: null;
const iamEmail = passengerId
? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null)
: null;
return {
phone: iamPhone ?? booking?.contactPhone ?? null,
email: iamEmail ?? booking?.contactEmail ?? null,
};
}
/** Shared context for every change template: who, where, when, which seats. */
private buildBookingChangeContext(booking: any, ref: string): Record<string, unknown> {
const { passengerName, trainSeatLines } = buildSeatSummary(
booking?.seats ?? [],
booking?.bookingType,
);
const segment = resolveBookingSegment(
booking?.schedule ?? {},
booking?.originStationId,
booking?.destinationStationId,
);
return {
passengerName,
bookingRef: ref,
origin: segment.origin?.name ?? '',
destination: segment.destination?.name ?? '',
trainSeatLines,
travelDate: this.fmtDate(segment.departureAt),
departureTime: this.fmtTime(segment.departureAt),
arrivalTime: this.fmtTime(segment.arrivalAt),
currency: 'ETB',
detailLink: `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`,
};
}
/**
* Re-fetch → interpolate → in-app + direct SMS/email.
*
* The event payload is not enough on its own: the reschedule/upgrade services' own
* `bookingInclude` selects `coach: { select: { id, coachTypeId } }` and no station names, so
* buildSeatSummary would render "-, seat no. N". Always read the booking back with
* CHANGE_INCLUDE.
*
* Every failure here is logged and swallowed — a notification must never take down the cron or
* the event emitter that invoked it, and the reschedule/upgrade itself is already committed.
*/
private async notifyBookingChange(
templateCode: string,
bookingId: string,
extra: Record<string, unknown>,
): Promise<void> {
try {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: NotificationsService.CHANGE_INCLUDE as any,
});
if (!booking) {
this.logger.warn(`${templateCode}: booking ${bookingId} not found — nothing sent`);
return;
}
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: templateCode },
});
if (!template || !template.active) {
this.logger.warn(`Template ${templateCode} not found or inactive`);
return;
}
const ref = (booking as any).bookingRef;
const context = { ...this.buildBookingChangeContext(booking, ref), ...extra };
const { subject, body } = this.interpolate(template, context);
const passengerId = (booking as any).passengerId ?? null;
if (passengerId) {
await this.createInAppNotification(passengerId, subject, body, {
category: 'BOOKING',
deepLink: `edr://bookings/${ref}`,
}).catch((err) =>
this.logger.warn(`${templateCode}: in-app notification failed for ${ref}: ${err}`),
);
}
const { phone, email } = await this.resolveDeliveryContacts(booking, passengerId);
if (!phone && !email) {
this.logger.warn(`${templateCode}: no contact details for booking ${ref} — nothing sent`);
return;
}
// Independent try/catch per channel: a dead SMS gateway must not cost the passenger
// their email too.
if (phone) {
try {
await this.smsClient.sendSms({ to: phone, message: body });
} catch (err) {
this.logger.warn(`${templateCode}: SMS failed for booking ${ref}: ${err}`);
}
}
if (email) {
try {
await this.emailClient.sendEmail({ to: email, subject, text: body });
} catch (err) {
this.logger.warn(`${templateCode}: email failed for booking ${ref}: ${err}`);
}
}
} catch (err) {
this.logger.error(`${templateCode}: notification failed for booking ${bookingId}: ${err}`);
}
}
private interpolate(
template: { subject?: string | null; bodyTemplate: string },
context: Record<string, unknown>,
@@ -731,22 +905,68 @@ export class NotificationsService {
);
}
/**
* Booking-change notifications (reschedule / upgrade, applied or expired).
*
* These deliberately do NOT go through `send()`. That path resolves an address only via
* `iam.users`, and `Passenger.iamUserId` is set on well under 2% of rows (and can dangle even
* when set), so EMAIL and SMS were silently skipped for almost every real booking while only the
* in-app row was written. They follow `onBookingCreated` instead: re-fetch, interpolate the
* template, then deliver straight to the booking's own contact details.
*/
@OnEvent('booking.rescheduled')
async onBookingRescheduled(payload: any) {
const { booking, reschedule } = payload;
await this.send(
'booking.rescheduled',
booking.passengerId,
{
bookingRef: booking.bookingRef,
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2),
currency: 'ETB',
category: 'BOOKING',
deepLink: `edr://bookings/${booking.bookingRef}`,
},
['IN_APP', 'EMAIL', 'SMS'],
);
let previousTravelDate = '';
if (reschedule?.oldScheduleId) {
const old = await this.prisma.trainSchedule
.findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } })
.catch(() => null);
// Pre-formatted so the template never renders a dangling 'Previously:' label.
previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)}
` : '';
}
await this.notifyBookingChange('booking.rescheduled', booking.id, {
leg: reschedule?.leg === 2 ? 'return' : 'outbound',
previousLine: previousTravelDate,
feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0),
amountPaid: this.fmtMinor(
(reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.upgraded')
async onBookingUpgraded(payload: any) {
const { booking, upgrade } = payload;
const items = Array.isArray(upgrade?.items) ? upgrade.items : [];
await this.notifyBookingChange('booking.upgraded', booking.id, {
leg: upgrade?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountPaid: this.fmtMinor(
(upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0),
),
});
}
@OnEvent('booking.reschedule.expired')
async onRescheduleExpired(payload: any) {
await this.notifyBookingChange('booking.reschedule.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.upgrade.expired')
async onUpgradeExpired(payload: any) {
const items = Array.isArray(payload.request?.items) ? payload.request.items : [];
await this.notifyBookingChange('booking.upgrade.expired', payload.bookingId, {
leg: payload.request?.leg === 2 ? 'return' : 'outbound',
passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '),
changeLines: this.buildUpgradeChangeLines(items),
amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0),
});
}
@OnEvent('booking.cancelled')

View File

@@ -1,3 +1,4 @@
import { SystemConfigModule } from '../system-config/system-config.module';
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
@@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
SystemConfigModule,
SeatsModule,
TicketsModule,
CurrencyModule,

View File

@@ -1,5 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { PaymentsService } from "./payments.service";
import { SystemConfigService } from "../system-config/system-config.service";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { PrismaService } from "../../common/prisma.service";
@@ -133,6 +134,8 @@ describe("PaymentsService", () => {
{ provide: PaymentClientService, useValue: mockPaymentClient },
{ provide: CurrencyService, useValue: mockCurrencyService },
{ provide: AuditService, useValue: { log: jest.fn() } },
// 120 = the default booking payment window; the deadline maths under test is unchanged by it.
{ provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(120) } },
],
}).compile();

View File

@@ -36,6 +36,7 @@ import {
MIN_PAYMENT_WINDOW_MINUTES,
PAYMENT_SETTLE_MARGIN_SECONDS,
} from "../../common/utils/payment-deadline.utils";
import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service";
import {
PaymentClientService,
PaymentDiagnostic,
@@ -88,6 +89,7 @@ export class PaymentsService {
private readonly waafiDemoTrustReturn = true;
constructor(
private systemConfig: SystemConfigService,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
@@ -748,7 +750,10 @@ export class PaymentsService {
originRouteStop?.checkinMinutesBefore ??
booking.schedule.route?.checkinMinutesBefore ??
undefined;
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
// Same window the auto-cancel cron uses, or the payer would be shown a deadline the cron
// does not honour.
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes, windowMinutes);
}
private resolveReturnUrls(

View File

@@ -122,7 +122,7 @@ export class ReportsController {
@Get("finance")
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, trip type, payment method, and currency",
summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency",
description:
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
"destination station pair, payment method, and currency. Amounts are never converted to ETB — a " +
@@ -131,10 +131,20 @@ export class ReportsController {
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
"whole predefined route. Pass `tripType` to split domestic from cross-border traffic: a trip is " +
"`intercity` only when both endpoints sit in Ethiopia, and `international` as soon as either endpoint " +
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Only " +
"counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " +
"dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " +
"segment, trip type, and method for charting.",
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Pass " +
"`bookingType` to separate travel-package revenue from ordinary ticket sales: `package` is a booking " +
"carrying a `packageId`, `regular` is one without — unrelated to the ONE_WAY/ROUND_TRIP booking type, " +
"and not counting the legacy standalone `PackageBooking` table, which this report has never included. " +
"Pass `revenueType` to isolate one revenue stream: `ticket` is the booking fare, `excess_baggage` a " +
"collected luggage fee, `outstanding` a recovered underpayment, and `other` every remaining " +
"supplementary charge (upgrade, reschedule, and any reason added later). Fare revenue counts " +
"CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same definition as the dashboard and the " +
"/payments confirmed-revenue filter. Charges count as collected on their own status (PAID, plus " +
"CASH_COLLECTED for baggage) and are deliberately NOT re-checked against the booking: a fee that was " +
"collected stays collected even if the booking is cancelled afterwards, unlike the fare. Charge rows " +
"report `UNKNOWN` as their payment method because settling a charge records only a providerTxnId, and " +
"a `method` filter therefore excludes them. Returns per-bucket rows plus roll-ups by period, segment, " +
"trip type, booking type, revenue type, and method for charting.",
})
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
return this.service.getFinanceSummary(query);

View File

@@ -124,6 +124,30 @@ export enum FinanceTripType {
INTERNATIONAL = 'international',
}
/**
* Travel-package revenue vs ordinary ticket sales, derived from `Booking.packageId`.
* NOT the `Booking.bookingType` column, which holds ONE_WAY / ROUND_TRIP — every package
* booking happens to be ROUND_TRIP, but that is a different question from this one.
*/
export enum FinanceBookingType {
REGULAR = 'regular',
PACKAGE = 'package',
}
/**
* Which revenue stream a row came from. `ticket` is the booking fare — all this report used to
* count. The rest are fees collected after the fare: `excess_baggage` from ExcessBaggageCharge,
* `outstanding` from a PAID SupplementaryCharge with reason UNDERPAYMENT, and `other` from every
* remaining supplementary reason (UPGRADE, RESCHEDULE, and anything added later — `reason` is a
* free-text column, so this bucket is deliberately open-ended).
*/
export enum FinanceRevenueType {
TICKET = 'ticket',
EXCESS_BAGGAGE = 'excess_baggage',
OUTSTANDING = 'outstanding',
OTHER = 'other',
}
export class FinanceSummaryQueryDto {
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateFrom: string;
@@ -150,4 +174,21 @@ export class FinanceSummaryQueryDto {
'Ethiopia (international — in practice Djibouti). Omit for all trips.',
})
@IsOptional() @IsEnum(FinanceTripType) tripType?: FinanceTripType;
@ApiPropertyOptional({
enum: FinanceBookingType,
description:
'Restrict to ordinary ticket sales (regular) or travel-package bookings (package). ' +
'Omit for all bookings. Unrelated to the ONE_WAY/ROUND_TRIP booking type.',
})
@IsOptional() @IsEnum(FinanceBookingType) bookingType?: FinanceBookingType;
@ApiPropertyOptional({
enum: FinanceRevenueType,
description:
'Restrict to one revenue stream: the booking fare (ticket), excess-baggage fees ' +
'(excess_baggage), recovered underpayments (outstanding), or every other supplementary ' +
'charge such as upgrades and reschedules (other). Omit for all revenue.',
})
@IsOptional() @IsEnum(FinanceRevenueType) revenueType?: FinanceRevenueType;
}

View File

@@ -11,7 +11,9 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
import {
BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto,
FinanceBookingType,
FinanceGranularity,
FinanceRevenueType,
FinanceSummaryQueryDto,
FinanceTripType,
GenerateReportDto,
@@ -148,12 +150,36 @@ function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
return date.toISOString().split("T")[0];
}
/** The `SupplementaryCharge.reason` that means a passenger under-paid and later settled up. */
const UNDERPAYMENT_REASON = "UNDERPAYMENT";
/**
* Payment method reported for a charge row. Settling a supplementary or excess-baggage fee
* records only a providerTxnId — the method is never stored — so charge revenue lands in one
* explicit bucket rather than being dropped from the method breakdown, which keeps that
* roll-up summing to the report total.
*/
const CHARGE_METHOD_UNKNOWN = "UNKNOWN";
/** The dimensions every revenue row takes off its booking, whether it is a fare or a fee. */
interface FinanceRowBooking {
originStationId: string | null;
destinationStationId: string | null;
packageId: string | null;
schedule: { originStationId: string; destinationStationId: string };
}
export interface FinanceBucket {
period: string;
originStationId: string;
destinationStationId: string;
segmentLabel: string;
tripType: FinanceTripType;
/** regular vs package — from `Booking.packageId`, not the ONE_WAY/ROUND_TRIP column. */
bookingType: FinanceBookingType;
/** Which revenue stream this row came from — the fare, or a fee collected after it. */
revenueType: FinanceRevenueType;
/** `UNKNOWN` on every charge row: nothing records how a supplementary or baggage fee was paid. */
method: string;
currency: string;
bookingCount: number;
@@ -1769,43 +1795,120 @@ export class ReportsService {
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
const granularity = query.granularity ?? FinanceGranularity.DAILY;
const bookings = await this.prisma.booking.findMany({
where: {
// Same revenue definition as the dashboard's backoffice-stats and the /payments
// "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking
// that was paid and later cancelled is not revenue) and the payment itself must have
// actually succeeded, not just carry a stale paidAt.
status: { in: ["CONFIRMED", "BOARDED"] },
paymentIntent: {
status: "SUCCEEDED",
paidAt: { gte: dateFrom, lte: dateTo },
...(query.method ? { method: query.method } : {}),
},
...(query.originStationId ? { originStationId: query.originStationId } : {}),
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
},
// Charge rows carry no payment method — paying a supplementary or baggage fee records only
// a providerTxnId — so a method filter can never match one, and asking for a charge stream
// means the booking query has nothing to contribute. Skip the queries the filter rules out
// rather than fetching rows that will all be discarded.
const wantsTicket = !query.revenueType || query.revenueType === FinanceRevenueType.TICKET;
const wantsCharges = !query.method && query.revenueType !== FinanceRevenueType.TICKET;
const wantsBaggage =
wantsCharges && (!query.revenueType || query.revenueType === FinanceRevenueType.EXCESS_BAGGAGE);
const wantsSupplementary =
wantsCharges &&
(!query.revenueType ||
query.revenueType === FinanceRevenueType.OUTSTANDING ||
query.revenueType === FinanceRevenueType.OTHER);
// The station / package filters reach a charge through its parent booking, exactly as they
// reach a fare through the booking itself.
const bookingScope = {
...(query.originStationId ? { originStationId: query.originStationId } : {}),
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
...(query.bookingType
? { packageId: query.bookingType === FinanceBookingType.PACKAGE ? { not: null } : null }
: {}),
};
// Every charge needs the same dimensions a fare row carries, and they all live on the booking.
const chargeBookingSelect = {
select: {
totalMinor: true,
currency: true,
displayTotalMinor: true,
displayCurrency: true,
originStationId: true,
destinationStationId: true,
packageId: true,
schedule: { select: { originStationId: true, destinationStationId: true } },
paymentIntent: { select: { paidAt: true, method: true } },
},
});
};
const bookings = wantsTicket
? await this.prisma.booking.findMany({
where: {
// Same revenue definition as the dashboard's backoffice-stats and the /payments
// "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking
// that was paid and later cancelled is not revenue) and the payment itself must have
// actually succeeded, not just carry a stale paidAt.
status: { in: ["CONFIRMED", "BOARDED"] },
paymentIntent: {
status: "SUCCEEDED",
paidAt: { gte: dateFrom, lte: dateTo },
...(query.method ? { method: query.method } : {}),
},
...bookingScope,
},
select: {
totalMinor: true,
packageId: true,
currency: true,
displayTotalMinor: true,
displayCurrency: true,
originStationId: true,
destinationStationId: true,
schedule: { select: { originStationId: true, destinationStationId: true } },
paymentIntent: { select: { paidAt: true, method: true } },
},
})
: [];
// Charges are NOT re-checked against the booking's status. A fee that was collected stays
// collected even if the booking is cancelled afterwards — unlike the fare, which this report
// drops on cancellation. CASH_COLLECTED counts as settled: it is the paid test the
// excess-baggage service itself uses, and it stamps paidAt when the cash is taken.
const baggageCharges = wantsBaggage
? await this.prisma.excessBaggageCharge.findMany({
where: {
status: { in: ["PAID", "CASH_COLLECTED"] },
paidAt: { gte: dateFrom, lte: dateTo },
...(Object.keys(bookingScope).length > 0 ? { booking: bookingScope } : {}),
},
select: { totalMinor: true, currency: true, paidAt: true, booking: chargeBookingSelect },
})
: [];
const supplementaryCharges = wantsSupplementary
? await this.prisma.supplementaryCharge.findMany({
where: {
status: "PAID",
paidAt: { gte: dateFrom, lte: dateTo },
...(query.revenueType === FinanceRevenueType.OUTSTANDING
? { reason: UNDERPAYMENT_REASON }
: {}),
...(query.revenueType === FinanceRevenueType.OTHER
? { reason: { not: UNDERPAYMENT_REASON } }
: {}),
...(Object.keys(bookingScope).length > 0 ? { booking: bookingScope } : {}),
},
select: {
amountMinor: true,
currency: true,
paidAt: true,
reason: true,
booking: chargeBookingSelect,
},
})
: [];
// Booking.originStationId/destinationStationId are set on every create path (guest and
// authenticated booking both pass them from the DTO); the schedule's own endpoints are
// only a fallback for the rare legacy row that predates those columns.
const stationIds = new Set<string>();
for (const b of bookings) {
const origin = b.originStationId ?? b.schedule.originStationId;
const destination = b.destinationStationId ?? b.schedule.destinationStationId;
const collectStations = (row: FinanceRowBooking) => {
const origin = row.originStationId ?? row.schedule.originStationId;
const destination = row.destinationStationId ?? row.schedule.destinationStationId;
if (origin) stationIds.add(origin);
if (destination) stationIds.add(destination);
}
};
for (const b of bookings) collectStations(b);
for (const c of baggageCharges) collectStations(c.booking);
for (const c of supplementaryCharges) collectStations(c.booking);
const stations = stationIds.size > 0
? await this.prisma.station.findMany({
where: { id: { in: [...stationIds] } },
@@ -1816,46 +1919,94 @@ export class ReportsService {
const stationCountry = new Map(stations.map((s) => [s.id, s.countryCode]));
const buckets = new Map<string, FinanceBucket>();
// `tripType` is a pure function of the station pair, so it never splits a bucket that the
// origin/destination part of the key hasn't split already — it rides along on the bucket
// rather than joining the key.
const bucketFor = (
period: string,
originStationId: string,
destinationStationId: string,
segmentLabel: string,
tripType: FinanceTripType,
/**
* One revenue item — a paid fare or a collected fee — folded into its bucket. Every source
* takes its dimensions off the same booking shape, so they share this path instead of each
* re-deriving the segment label and the trip-type rule.
*
* `tripType` is a pure function of the station pair, so it never splits a bucket that the
* origin/destination part of the key hasn't split already — it rides along on the bucket
* rather than joining the key. `bookingType` and `revenueType` are not: a package and a
* regular booking, or a baggage fee and an underpayment on the same booking, can share every
* other dimension, so both belong in the key or a mixed bucket would take whichever label
* happened to land first.
*/
const addRow = (
booking: FinanceRowBooking,
paidAt: Date,
revenueType: FinanceRevenueType,
method: string,
currency: string,
): FinanceBucket => {
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
amountMinor: number,
) => {
const originStationId = booking.originStationId ?? booking.schedule.originStationId ?? "UNKNOWN";
const destinationStationId =
booking.destinationStationId ?? booking.schedule.destinationStationId ?? "UNKNOWN";
// Filtered here rather than pushed into the Prisma `where`: the endpoints that decide the
// trip type are `booking.originStationId ?? schedule.originStationId`, and a
// `{ in: ethiopianStationIds }` clause would mis-bucket any legacy row whose booking-level
// station columns are null.
const tripType = tripTypeFor(
stationCountry.get(originStationId),
stationCountry.get(destinationStationId),
);
if (query.tripType && tripType !== query.tripType) return;
const period = periodKeyFor(paidAt, granularity);
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"}${stationName.get(destinationStationId) ?? "Unknown"}`;
const bookingType = booking.packageId ? FinanceBookingType.PACKAGE : FinanceBookingType.REGULAR;
const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${revenueType}|${method}|${currency}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 };
bucket = {
period,
originStationId,
destinationStationId,
segmentLabel,
tripType,
bookingType,
revenueType,
method,
currency,
bookingCount: 0,
revenueMinor: 0,
};
buckets.set(key, bucket);
}
return bucket;
bucket.bookingCount += 1;
bucket.revenueMinor += amountMinor;
};
for (const b of bookings) {
const pi = b.paymentIntent!;
const period = periodKeyFor(pi.paidAt!, granularity);
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"}${stationName.get(destinationStationId) ?? "Unknown"}`;
addRow(
b,
pi.paidAt!,
FinanceRevenueType.TICKET,
pi.method,
(b.displayCurrency as string | null) ?? b.currency,
b.displayTotalMinor ?? b.totalMinor,
);
}
// Filtered here rather than pushed into the Prisma `where`: the endpoints that decide
// the trip type are `booking.originStationId ?? schedule.originStationId`, and a
// `{ in: ethiopianStationIds }` clause would mis-bucket any legacy row whose
// booking-level station columns are null.
const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId));
if (query.tripType && tripType !== query.tripType) continue;
for (const c of baggageCharges) {
addRow(
c.booking,
c.paidAt!,
FinanceRevenueType.EXCESS_BAGGAGE,
CHARGE_METHOD_UNKNOWN,
c.currency,
c.totalMinor,
);
}
const currency = (b.displayCurrency as string | null) ?? b.currency;
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency);
bucket.bookingCount += 1;
bucket.revenueMinor += amountMinor;
for (const c of supplementaryCharges) {
const revenueType =
c.reason === UNDERPAYMENT_REASON ? FinanceRevenueType.OUTSTANDING : FinanceRevenueType.OTHER;
addRow(c.booking, c.paidAt!, revenueType, CHARGE_METHOD_UNKNOWN, c.currency, c.amountMinor);
}
const rows = [...buckets.values()].sort((a, b) =>
@@ -1888,11 +2039,15 @@ export class ReportsService {
dateFrom: query.dateFrom,
dateTo: query.dateTo,
tripType: query.tripType ?? null,
bookingType: query.bookingType ?? null,
revenueType: query.revenueType ?? null,
totals,
byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period),
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel),
byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method),
byTripType: rollUp((r) => `${r.tripType}|${r.currency}`, (r) => r.tripType),
byBookingType: rollUp((r) => `${r.bookingType}|${r.currency}`, (r) => r.bookingType),
byRevenueType: rollUp((r) => `${r.revenueType}|${r.currency}`, (r) => r.revenueType),
rows,
};
}
@@ -1901,11 +2056,13 @@ export class ReportsService {
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
const report = await this.getFinanceSummary(query);
const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"];
const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Revenue Type", "Payment Method", "Currency", "Items", "Revenue"];
const rows = report.rows.map((r) => [
r.period,
r.segmentLabel,
r.tripType,
r.bookingType,
r.revenueType,
r.method,
r.currency,
r.bookingCount,

View File

@@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { RescheduleController } from './reschedule.controller';
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
@@ -32,7 +33,7 @@ export class RescheduleEventsListener {
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [RescheduleController],
providers: [RescheduleService, RescheduleEventsListener],
exports: [RescheduleService],

View File

@@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { MeLikeUser } from '../../common/passenger-permission.util';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { normalizePhone, samePhone } from '../../common/utils/phone.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
@@ -85,6 +86,8 @@ type LegView = {
departureAt: Date;
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
coachTypeId: string;
/** Every distinct coach type on the leg. More than one means a partial upgrade happened. */
coachTypeIds: string[];
};
// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
@@ -109,6 +112,7 @@ export class RescheduleService {
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
private systemConfig: SystemConfigService,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
@@ -267,7 +271,8 @@ export class RescheduleService {
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
const newDeparture = q.newDepartureAt;
const expiresAt = computePaymentDeadline(new Date(), newDeparture);
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES);
const expiresAt = computePaymentDeadline(new Date(), newDeparture, undefined, windowMinutes);
const reschedule = await this.prisma.bookingReschedule.create({
data: {
@@ -314,7 +319,8 @@ export class RescheduleService {
where: { id: reschedule.id },
data: { supplementaryChargeId: charge.id },
});
await this.seatsService.confirmSeats(dto.newSeatIds);
// Same instant the charge carries, so the hold and the payment link die together.
await this.seatsService.confirmSeats(dto.newSeatIds, new Date(), expiresAt);
await this.auditService.log({
userId: requestedBy,
@@ -427,7 +433,7 @@ export class RescheduleService {
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingReschedule.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true },
});
for (const r of stale) {
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
@@ -437,7 +443,22 @@ export class RescheduleService {
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (r.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.reschedule.expired', { bookingId: s.bookingId, request: s });
}
return stale.length;
}
@@ -497,11 +518,11 @@ export class RescheduleService {
.map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId }));
const l1 = seatsOf(1);
if (l1.length && booking.schedule) {
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId });
legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId, coachTypeIds: [...new Set(l1.map((s) => s.coachTypeId))] });
}
const l2 = seatsOf(2);
if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) {
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId });
legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId, coachTypeIds: [...new Set(l2.map((s) => s.coachTypeId))] });
}
return legs;
}
@@ -521,6 +542,13 @@ export class RescheduleService {
// round trip whose outbound was already used can't change its return yet — needs leg-scoped
// ticket regeneration.
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
// A partial fare-class upgrade can leave one leg spanning two coach types. Everything below
// — the policy lookup, the fee, the seat map — keys off a single leg-wide class taken from
// the first seat, so a mixed leg would silently reschedule at the wrong class and price.
// Refuse it outright until reschedule is made class-aware per passenger.
if (leg.coachTypeIds.length > 1) {
blockers.push('This booking has passengers in different fare classes. Please contact support to change it.');
}
if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.');
else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) {
blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`);
@@ -537,6 +565,10 @@ export class RescheduleService {
const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pending) blockers.push('A reschedule is already awaiting payment for this booking.');
// One change at a time. Two live supplementary charges would both drive ticket regeneration
// on this booking and interleave unpredictably once each is paid.
const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } });
if (pendingUpgrade) blockers.push('An upgrade is awaiting payment for this booking — finish or cancel it first.');
if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`);
if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.');

View File

@@ -701,7 +701,14 @@ export class SeatsService {
// short seat-selection hold (5 min by default). Without this, the hold could expire
// while the customer was still on the payment page, and a second customer could
// hold/book the exact same seat out from under them.
async confirmSeats(seatIds: string[], now: Date = new Date()): Promise<void> {
/**
* `deadlineOverride` pins the hold to a deadline the caller has already computed. The
* reschedule and upgrade flows pass the exact value their supplementary charge carries — if
* this recomputed it instead, a per-flow payment window would give the hold and the payment
* link different lifetimes and the seat could lapse while the link still worked.
* Without it, the booking payment window is used, as before.
*/
async confirmSeats(seatIds: string[], now: Date = new Date(), deadlineOverride?: Date): Promise<void> {
if (seatIds.length === 0) return;
const holds = await this.prisma.seatHold.findMany({
@@ -717,24 +724,46 @@ export class SeatsService {
});
const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
let extended = 0;
const windowMinutes = deadlineOverride
? 0 // unused — the override wins below
: await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
let aligned = 0;
await Promise.all(
holds.map(async (hold) => {
const departureAt = departureById.get(hold.scheduleId);
if (!departureAt) return;
const deadline = computePaymentDeadline(now, departureAt);
// Only ever extend forward — never shorten a hold that's already valid longer
// than the payment deadline would give it (e.g. a second confirmSeats call on
// the same booking, or a hold that was already extended).
if (deadlineOverride) {
// Authoritative in BOTH directions. The caller already issued a payment link with this
// exact deadline, so the hold must match it — including when it is EARLIER than the
// hold's own TTL. Extending only would leave the seat held after the link that pays for
// it has died (reachable whenever a flow's payment window is shorter than
// seat_hold_duration_minutes), so the seat sits unsellable in between.
if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return;
await this.prisma.seatHold.update({
where: { id: hold.id },
data: { expiresAt: deadlineOverride },
});
aligned++;
return;
}
const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes);
// Normal booking path: only ever extend forward — never shorten a hold that's already
// valid longer than the payment deadline would give it. A round trip calls confirmSeats
// up to four times, and a later call must not pull in a hold an earlier one set.
if (deadline <= hold.expiresAt) return;
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
extended++;
aligned++;
}),
);
if (extended > 0) {
if (aligned > 0) {
this.logger.log(
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
deadlineOverride
? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline`
: `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
);
}
}

View File

@@ -1,6 +1,7 @@
import { IsInt, IsOptional, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { MIN_PAYMENT_WINDOW_MINUTES } from '../../common/utils/payment-deadline.utils';
/**
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
@@ -22,6 +23,23 @@ export class UpdateSystemConfigDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
boarding_window_hours_before_departure?: number;
// Payment windows, in minutes. Floored at MIN_PAYMENT_WINDOW_MINUTES (7) because canOpenPaymentSession
// refuses to open a provider session with less than that left — a window below it makes every
// card/HPP payment impossible to start. Capped at 1440 (24h) — the check-in cutoff already bounds the
// effective deadline, but a stray 100000 would make the auto-cancel pre-filter scan pointlessly
// far back.
@ApiPropertyOptional({ example: 120, description: 'Minutes a new booking has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
booking_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a reschedule charge has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
reschedule_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 120, description: 'Minutes a fare upgrade has to be paid (7..1440)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440)
upgrade_payment_window_minutes?: number;
@ApiPropertyOptional({ example: 5 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
throttle_auth_limit?: number;

View File

@@ -5,6 +5,12 @@ export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure',
// How long a passenger has to pay, per flow. The effective deadline is always
// MIN(now + window, departure - check-in cutoff) — a longer window can never let someone pay
// after boarding closes.
BOOKING_PAYMENT_WINDOW_MINUTES: 'booking_payment_window_minutes',
RESCHEDULE_PAYMENT_WINDOW_MINUTES: 'reschedule_payment_window_minutes',
UPGRADE_PAYMENT_WINDOW_MINUTES: 'upgrade_payment_window_minutes',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
@@ -17,6 +23,10 @@ const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4',
// 120 = the 2 hours these flows used before the window became configurable.
[CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES]: '120',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',

View File

@@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { RescheduleService } from '../reschedule/reschedule.service';
import { UpgradeService } from '../upgrade/upgrade.service';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -44,6 +46,8 @@ export class TasksService {
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
private readonly moduleRef: ModuleRef,
// Singleton (only injects Prisma), so it does not drag request scope in and silence the crons.
private readonly systemConfig: SystemConfigService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -177,6 +181,7 @@ export class TasksService {
// ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
// Only look at bookings created within the last 3 h with a future departure.
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
@@ -216,7 +221,7 @@ export class TasksService {
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -260,7 +265,10 @@ export class TasksService {
// ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
// Must be the SAME window the payment link was issued with, or a shortened window would
// leave older bookings unselected by the pre-filter and never auto-cancelled.
const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
const windowAgo = new Date(now.getTime() - bookingWindowMinutes * 60 * 1000);
// The departure pre-filter below is a query-scoping optimization only — the real
// deadline check happens per-row further down. It must be widened to the largest
@@ -286,7 +294,7 @@ export class TasksService {
where: {
status: 'PENDING_PAYMENT',
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ createdAt: { lte: windowAgo } },
{ schedule: { departureAt: { lte: departureCutoff } } },
],
},
@@ -329,7 +337,7 @@ export class TasksService {
(s: any) => s.stationId === (booking as any).originStationId,
);
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes);
if (now < paymentDeadline) continue;
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
@@ -526,6 +534,22 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: fare-class upgrades whose payment deadline passed → EXPIRED.
// Separate from the reschedule sweep on purpose — a failure in one must not
// skip the other.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async expireStaleUpgrades() {
try {
const upgrade = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
const n = await upgrade.expireStale();
if (n > 0) this.logger.log(`Expired ${n} unpaid upgrade request(s)`);
} catch (err) {
this.logger.error(`expireStaleUpgrades failed: ${err instanceof Error ? err.message : err}`);
}
}
@Cron('0 2 * * *')
async purgeExpiredData() {
const now = new Date();

View File

@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service';
import {
CreateUpgradeDto,
CreateUpgradePolicyDto,
UpgradeHoldDto,
UpgradeQuoteDto,
UpdateUpgradePolicyDto,
} from './upgrade.dto';
@ApiTags('Fare upgrade')
@Controller()
export class UpgradeController {
constructor(private service: UpgradeService) {}
@Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@Get('upgrade/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
return this.service.listUnconfiguredCoachTypes();
}
@Post('upgrade/policies')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
return this.service.createPolicy(dto, req.user?.id);
}
@Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
}
@Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
return this.service.deletePolicy(coachTypeId, req.user?.id);
}
@Get('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Per-leg upgrade eligibility, per-passenger targets, pending request and history' })
options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
return this.service.getOptions(bookingRef, req.user);
}
@Post('bookings/:bookingRef/upgrade/quote')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Price an upgrade without committing to it' })
quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeQuoteDto) {
return this.service.quote(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade/hold')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Hold the chosen seats, clearing abandoned attempts on this booking first' })
hold(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeHoldDto) {
return this.service.holdForUpgrade(bookingRef, dto, req.user);
}
@Post('bookings/:bookingRef/upgrade')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Request an upgrade; returns a payment token when money is owed' })
create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateUpgradeDto) {
return this.service.create(bookingRef, dto, req.user);
}
}

View File

@@ -0,0 +1,94 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class UpdateUpgradePolicyDto {
@ApiPropertyOptional({ example: 2, description: 'Ladder position — an upgrade needs a strictly higher rank' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
rank?: number;
@ApiPropertyOptional({ example: 0, description: '% of the passenger\'s original fare charged as a change fee' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
feePercent?: number;
@ApiPropertyOptional({ example: 0, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
feeMinMinor?: number;
@ApiPropertyOptional({ example: true, description: 'Waive the change fee entirely (policy US-17 §5)' })
@IsOptional() @IsBoolean()
feeWaived?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade OUT of this class' })
@IsOptional() @IsBoolean()
isUpgradable?: boolean;
@ApiPropertyOptional({ example: true, description: 'Passengers may upgrade INTO this class' })
@IsOptional() @IsBoolean()
isTargetable?: boolean;
@ApiPropertyOptional({ example: true })
@IsOptional() @IsBoolean()
isActive?: boolean;
}
export class CreateUpgradePolicyDto extends UpdateUpgradePolicyDto {
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType this policy applies to (one per fare class)' })
@IsString()
coachTypeId: string;
}
export class UpgradeItemDto {
@ApiProperty({ example: 'booking-seat-uuid', description: 'The BookingSeat row being upgraded' })
@IsString()
bookingSeatId: string;
@ApiProperty({ example: 'seat-uuid', description: 'Seat this passenger moves to, in the target coach type' })
@IsString()
newSeatId: string;
}
export class UpgradeQuoteDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ example: 'coach-type-uuid', description: 'Fare class every listed passenger is moving to' })
@IsString()
newCoachTypeId: string;
@ApiProperty({
type: [UpgradeItemDto],
description:
'One entry per upgrading passenger. Keyed on bookingSeatId, not array position — only some ' +
'passengers move, so a positional pairing would be ambiguous.',
})
@IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => UpgradeItemDto)
items: UpgradeItemDto[];
}
export class UpgradeHoldDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
leg?: number;
@ApiProperty({ type: [String], description: 'Seats to hold, in the target coach type' })
@IsArray() @ArrayMinSize(1) @IsString({ each: true })
seatIds: string[];
}
export class CreateUpgradeDto extends UpgradeQuoteDto {
@ApiProperty({ example: 'seat-hold-uuid', description: 'Hold covering every newSeatId' })
@IsString()
holdId: string;
}

View File

@@ -0,0 +1,46 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnEvent } from '@nestjs/event-emitter';
import { AuditModule } from '../../common/audit.module';
import { BookingsModule } from '../bookings/bookings.module';
import { SeatsModule } from '../seats/seats.module';
import { SegmentsModule } from '../segments/segments.module';
import { TicketsModule } from '../tickets/tickets.module';
import { PaymentsModule } from '../payments/payments.module';
import { CurrencyModule } from '../currency/currency.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { SUPPLEMENTARY_CHARGE_PAID_EVENT } from '../reschedule/reschedule.service';
import { UpgradeController } from './upgrade.controller';
import { UpgradeService } from './upgrade.service';
/**
* Same shape and same reason as RescheduleEventsListener: UpgradeService is request-scoped by
* transitivity (AuditService injects REQUEST), and Nest never fires @OnEvent on request-scoped
* providers — so the listener is a singleton that resolves the service per event.
*
* Two listeners on one event is fine: each looks its charge up by its own unique
* `supplementaryChargeId` and returns silently when the charge is not theirs.
*/
@Injectable()
export class UpgradeEventsListener {
private readonly logger = new Logger(UpgradeEventsListener.name);
constructor(private readonly moduleRef: ModuleRef) {}
@OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true })
async onChargePaid(payload: { chargeId: string }) {
try {
const service = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false });
await service.applyForCharge(payload.chargeId);
} catch (err) {
this.logger.error(`Failed to apply upgrade for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
}
}
}
@Module({
imports: [AuditModule, BookingsModule, SeatsModule, SegmentsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule],
controllers: [UpgradeController],
providers: [UpgradeService, UpgradeEventsListener],
exports: [UpgradeService],
})
export class UpgradeModule {}

View File

@@ -0,0 +1,52 @@
import { computeUpgradeAmounts } from './upgrade.service';
// Pure arithmetic only, mirroring reschedule.service.spec.ts — no Nest test module, no mocks.
describe('computeUpgradeAmounts', () => {
const free = { feePercent: 0, feeMinMinor: 0, feeWaived: false };
const waived = { feePercent: 30, feeMinMinor: 50000, feeWaived: true };
const percentOnly = { feePercent: 10, feeMinMinor: 0, feeWaived: false };
const flooredFee = { feePercent: 10, feeMinMinor: 50000, feeWaived: false };
it('charges only the fare difference when the class has no fee', () => {
// RS 1752.34 → EBC 2336.46, as seeded on dev
expect(computeUpgradeAmounts(free, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('ignores a configured fee when the policy waives it (US-17 §5)', () => {
expect(computeUpgradeAmounts(waived, 175234, 233646)).toEqual({
feeMinor: 0,
fareDifferenceMinor: 58412,
amountDueMinor: 58412,
});
});
it('takes the fee as a percentage of the ORIGINAL fare, not of the difference', () => {
const r = computeUpgradeAmounts(percentOnly, 175234, 233646);
expect(r.feeMinor).toBe(17523); // 10% of 175234, not of 58412
expect(r.amountDueMinor).toBe(17523 + 58412);
});
it('applies the fee floor when the percentage falls below it', () => {
const r = computeUpgradeAmounts(flooredFee, 100000, 150000);
expect(r.feeMinor).toBe(50000); // max(10% of 100000 = 10000, floor 50000)
expect(r.amountDueMinor).toBe(100000);
});
it('never lets a negative difference reduce the amount due', () => {
// Refused upstream, but the arithmetic must not produce a credit if it ever gets here.
const r = computeUpgradeAmounts(percentOnly, 200000, 150000);
expect(r.fareDifferenceMinor).toBe(-50000);
expect(r.amountDueMinor).toBe(r.feeMinor);
expect(r.amountDueMinor).toBeGreaterThanOrEqual(0);
});
it('charges the full target fare for a free child', () => {
const r = computeUpgradeAmounts(free, 0, 233646);
expect(r.feeMinor).toBe(0); // a percentage of zero is zero
expect(r.amountDueMinor).toBe(233646);
});
});

View File

@@ -0,0 +1,874 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service';
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
import {
ActingUser,
isNonFareCoachType,
loadOwnedBooking,
NOT_A_FARE_CLASS,
pickSeatClass,
resolveNationalityProxy,
} from '../../common/utils/booking-change.utils';
import { BookingsService } from '../bookings/bookings.service';
import { SeatsService } from '../seats/seats.service';
import { SegmentsService } from '../segments/segments.service';
import { TicketsService } from '../tickets/tickets.service';
import { PaymentsService } from '../payments/payments.service';
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
import { JourneyDirection } from '../seats/seats.dto';
import {
CreateUpgradeDto,
CreateUpgradePolicyDto,
UpgradeHoldDto,
UpgradeQuoteDto,
UpdateUpgradePolicyDto,
} from './upgrade.dto';
export const UPGRADE_CHARGE_REASON = 'UPGRADE';
type PolicyFee = { feePercent: number; feeMinMinor: number; feeWaived: boolean };
/**
* Pure fee arithmetic for one upgrading passenger — policy US-17. The fee is read from the class
* being upgraded TO (§5 waives it for the premium classes), and is a percentage of that
* passenger's ORIGINAL fare, not of the difference.
*
* A non-positive difference never produces a credit: an upgrade that prices below the current
* seat is refused upstream rather than refunded here (see `buildQuote`).
*/
export function computeUpgradeAmounts(
policy: PolicyFee,
oldFareMinor: number,
newFareMinor: number,
): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } {
const feeMinor = policy.feeWaived
? 0
: policy.feePercent > 0 || policy.feeMinMinor > 0
? Math.max(Math.round((oldFareMinor * policy.feePercent) / 100), policy.feeMinMinor)
: 0;
const fareDifferenceMinor = newFareMinor - oldFareMinor;
return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) };
}
type UpgradeItem = {
bookingSeatId: string;
passengerName: string;
passengerCategory: string;
oldSeatId: string;
oldSeatLabel: string | null;
oldCoachTypeId: string;
oldSeatClassId: string | null;
oldFareMinor: number;
newSeatId: string;
newSeatLabel: string | null;
newCoachTypeId: string;
newSeatClassId: string | null;
newFareMinor: number;
feeMinor: number;
fareDifferenceMinor: number;
};
// Seats ordered the same way the reschedule flow orders them, so both features present a leg's
// passengers in one stable sequence. Upgrade itself keys on bookingSeatId, not position.
const bookingInclude = {
schedule: {
select: {
id: true, departureAt: true, arrivalAt: true, status: true,
originStationId: true, destinationStationId: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
returnSchedule: {
select: {
id: true, departureAt: true, arrivalAt: true, status: true,
originStationId: true, destinationStationId: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
},
},
seats: {
include: { seat: { include: { coach: { select: { id: true, coachTypeId: true } } } } },
orderBy: [{ passengerName: 'asc' as const }, { id: 'asc' as const }],
},
} satisfies Prisma.BookingInclude;
@Injectable()
export class UpgradeService {
private readonly logger = new Logger(UpgradeService.name);
constructor(
private prisma: PrismaService,
private bookingsService: BookingsService,
private seatsService: SeatsService,
private segmentsService: SegmentsService,
private ticketsService: TicketsService,
private paymentsService: PaymentsService,
private supplementaryCharges: SupplementaryChargesService,
private currencyService: CurrencyService,
private auditService: AuditService,
private eventEmitter: EventEmitter2,
private systemConfig: SystemConfigService,
) {}
// ── Policy admin ─────────────────────────────────────────────────────────
async listPolicies() {
return this.prisma.upgradePolicy.findMany({
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
orderBy: { rank: 'asc' },
});
}
/** Fare classes with no upgrade policy yet — the add dialog's dropdown. */
async listUnconfiguredCoachTypes() {
return this.prisma.coachType.findMany({
where: { ...NOT_A_FARE_CLASS, upgradePolicy: { is: null } },
select: { id: true, code: true, name: true, type: true },
orderBy: { code: 'asc' },
});
}
async createPolicy(dto: CreateUpgradePolicyDto, actorId?: string) {
const { coachTypeId, ...values } = dto;
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
if (isNonFareCoachType(coachType)) {
throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
}
const existing = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } });
if (existing) throw new ConflictException(`${coachType.code} already has an upgrade policy — edit it instead.`);
await this.assertRankIsFree(values.rank ?? 0, null);
const policy = await this.prisma.upgradePolicy.create({ data: { coachTypeId, ...values } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
newData: { coachTypeCode: coachType.code, ...values },
});
return policy;
}
async updatePolicy(coachTypeId: string, dto: UpdateUpgradePolicyDto, actorId?: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
const before = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } });
if (dto.rank !== undefined) await this.assertRankIsFree(dto.rank, coachTypeId);
const policy = await this.prisma.upgradePolicy.upsert({
where: { coachTypeId },
update: dto,
create: { coachTypeId, ...dto },
});
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
oldData: before ?? undefined,
newData: { coachTypeCode: coachType.code, ...dto },
});
return policy;
}
async deletePolicy(coachTypeId: string, actorId?: string) {
const policy = await this.prisma.upgradePolicy.findUnique({
where: { coachTypeId },
include: { coachType: { select: { code: true } } },
});
if (!policy) throw new NotFoundException('Upgrade policy not found');
await this.prisma.upgradePolicy.delete({ where: { coachTypeId } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.UpgradePolicy,
entityId: policy.id,
oldData: policy,
});
// With no policy the class can be neither left nor entered — the intended effect of deleting.
return { deleted: true, coachTypeId };
}
/**
* Two active policies sharing a rank make "strictly higher" undefined, so the ladder must stay
* a total order.
*/
private async assertRankIsFree(rank: number, exceptCoachTypeId: string | null) {
const clash = await this.prisma.upgradePolicy.findFirst({
where: { rank, isActive: true, ...(exceptCoachTypeId ? { coachTypeId: { not: exceptCoachTypeId } } : {}) },
include: { coachType: { select: { code: true } } },
});
if (clash) {
throw new ConflictException(`Rank ${rank} is already used by ${clash.coachType.code}. Ranks must be unique.`);
}
}
// ── Reads ────────────────────────────────────────────────────────────────
/** Per leg: who can upgrade, to which classes, and roughly what it costs. */
async getOptions(bookingRef: string, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const legs = this.legsOf(booking);
const pending = await this.prisma.bookingUpgrade.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
const charge = pending?.supplementaryChargeId
? await this.prisma.supplementaryCharge.findUnique({
where: { id: pending.supplementaryChargeId },
select: { paymentToken: true, status: true, expiresAt: true },
})
: null;
const out = [];
for (const leg of legs) {
const blockers = await this.legBlockers(booking, leg);
const targets = await this.targetsFor(leg);
// Each passenger's own class decides what counts as "up" for them, so the source policy
// has to be resolved per seat — on a mixed-class booking they differ.
const sourcePolicies = await this.prisma.upgradePolicy.findMany({
where: { coachTypeId: { in: [...new Set(leg.seats.map((s: any) => s.coachTypeId as string).filter(Boolean))] as string[] } },
});
const sourceByCoachType = new Map(sourcePolicies.map((p) => [p.coachTypeId, p]));
const passengers = leg.seats.map((s: any) => {
const source = sourceByCoachType.get(s.coachTypeId);
const canLeave = !!source && source.isActive && source.isUpgradable;
return {
bookingSeatId: s.id,
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
seatId: s.seatId,
seatLabel: s.seatLabel,
coachTypeId: s.coachTypeId,
currentRank: source?.rank ?? null,
currentFareMinor: s.fareMinor ?? 0,
// A passenger can only move up from where they actually sit, which on a mixed-class
// booking differs per passenger. No policy on their current class means they cannot
// leave it at all.
targets: canLeave
? targets.filter((t) => t.rank > source!.rank && t.coachTypeId !== s.coachTypeId)
: [],
};
});
out.push({
leg: leg.leg,
scheduleId: leg.scheduleId,
originStationId: leg.originStationId,
destinationStationId: leg.destinationStationId,
departureAt: leg.departureAt,
checkinCutoffAt: leg.checkin?.cutoffAt ?? null,
checkinMinutes: leg.checkin?.checkinMinutes ?? null,
canUpgrade: blockers.length === 0 && passengers.some((p: any) => p.targets.length > 0),
blockers,
passengers,
});
}
const history = await this.prisma.bookingUpgrade.findMany({
where: { bookingId: booking.id, status: { not: 'PENDING_PAYMENT' } },
orderBy: { createdAt: 'desc' },
});
return {
bookingRef: booking.bookingRef,
bookingType: booking.bookingType,
legs: out,
pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null,
history,
};
}
// ── Quote / create / apply ───────────────────────────────────────────────
/**
* Takes the seat hold for an upgrade attempt.
*
* Server-side rather than letting the portal call `/seats/hold` directly, because an upgrade
* holds on the SAME schedule the booking already occupies — so a retry collides with the
* caller's own abandoned attempt: first on the synthetic passenger id, and if they re-pick the
* same seat, on the seat itself. Clearing this booking's own stale upgrade holds first is the
* only way a passenger can change their mind inside the hold TTL. Deriving the schedule and
* stations from the booking instead of trusting the client is a bonus.
*/
async holdForUpgrade(bookingRef: string, dto: UpgradeHoldDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const legNo = dto.leg ?? 1;
const leg = this.legsOf(booking).find((l) => l.leg === legNo);
if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`);
await this.releaseAbandonedHolds(booking.bookingRef, leg.scheduleId);
return this.seatsService.holdSeats({
scheduleId: leg.scheduleId,
originStationId: leg.originStationId,
destinationStationId: leg.destinationStationId,
journeyDirection: legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY,
// Synthetic ids: there is no real passenger id to hand, and holdSeats only uses them to
// stop one passenger holding two seats on a leg. Tagged with the booking ref so this
// booking's own abandoned attempts can be told apart from anyone else's hold.
passengers: dto.seatIds.map((seatId, i) => ({
passengerId: `${this.upgradeHoldPrefix(bookingRef)}${i}`,
seatId,
})),
} as any);
}
private upgradeHoldPrefix(bookingRef: string) {
return `upgrade-${bookingRef}-`;
}
/**
* Deletes holds this booking's own earlier upgrade attempts left behind, except one already
* committed to a PENDING_PAYMENT upgrade (that one is paid-for and must survive).
*/
private async releaseAbandonedHolds(bookingRef: string, scheduleId: string) {
const prefix = this.upgradeHoldPrefix(bookingRef);
const live = await this.prisma.bookingUpgrade.findMany({
where: { status: 'PENDING_PAYMENT', holdId: { not: null } },
select: { holdId: true },
});
const committed = new Set(live.map((u) => u.holdId!));
const holds = await this.prisma.seatHold.findMany({ where: { scheduleId } });
const mine = holds.filter((h) => {
if (committed.has(h.id)) return false;
if (!h.createdBy?.trimStart().startsWith('{')) return false;
try {
const meta = JSON.parse(h.createdBy);
return (meta.passengers ?? []).some((p: any) => String(p.passengerId ?? '').startsWith(prefix));
} catch {
return false;
}
});
if (mine.length) {
await this.prisma.seatHold.deleteMany({ where: { id: { in: mine.map((h) => h.id) } } });
this.logger.log(`Released ${mine.length} abandoned upgrade hold(s) for ${bookingRef}`);
}
}
async quote(bookingRef: string, dto: UpgradeQuoteDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
return this.buildQuote(booking, dto);
}
async create(bookingRef: string, dto: CreateUpgradeDto, user: ActingUser) {
const booking = await this.load(bookingRef, user);
const q = await this.buildQuote(booking, dto, { skipAvailability: true });
if (!q.allowed) throw new BadRequestException(q.blockers.join(' '));
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
if (hold.scheduleId !== q.scheduleId) throw new BadRequestException('Seat hold is for a different schedule');
const held = new Set(hold.seatIds);
if (!q.items.every((it) => held.has(it.newSeatId))) {
throw new BadRequestException('Selected seats are not covered by the hold');
}
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
// Deadline is the earlier of the usual 2h payment window and the check-in cutoff, so a
// passenger can never pay for an upgrade after boarding has closed on it.
const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES);
const expiresAt = computePaymentDeadline(
new Date(),
q.checkin.segmentTime,
q.checkin.checkinMinutes,
windowMinutes,
);
const upgrade = await this.prisma.bookingUpgrade.create({
data: {
bookingId: booking.id,
leg: q.leg,
status: 'PENDING_PAYMENT',
requestedBy,
scheduleId: q.scheduleId,
items: q.items as unknown as Prisma.InputJsonValue,
holdId: dto.holdId,
oldFareMinor: q.oldFareMinor,
newFareMinor: q.newFareMinor,
fareDifferenceMinor: q.fareDifferenceMinor,
feeMinor: q.feeMinor,
amountDueMinor: q.amountDueMinor,
expiresAt: q.amountDueMinor > 0 ? expiresAt : null,
},
});
if (q.amountDueMinor === 0) {
await this.apply(upgrade.id);
return { upgradeId: upgrade.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q };
}
const charge = await this.supplementaryCharges.create({
bookingRef: booking.bookingRef,
amountMinor: q.amountDueMinor,
reason: UPGRADE_CHARGE_REASON,
notes: `Upgrade leg ${q.leg}${q.newCoachTypeCode} (${q.items.length} passenger(s))`,
createdBy: requestedBy,
expiresAt,
});
await this.prisma.bookingUpgrade.update({
where: { id: upgrade.id },
data: { supplementaryChargeId: charge.id },
});
// Same instant the charge carries, so the hold and the payment link die together.
await this.seatsService.confirmSeats(q.items.map((it) => it.newSeatId), new Date(), expiresAt);
await this.auditService.log({
userId: requestedBy,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.BookingUpgrade,
entityId: upgrade.id,
newData: {
bookingRef: booking.bookingRef,
leg: q.leg,
newCoachTypeId: dto.newCoachTypeId,
amountDueMinor: q.amountDueMinor,
chargeId: charge.id,
},
});
return {
upgradeId: upgrade.id,
status: 'PENDING_PAYMENT',
amountDueMinor: q.amountDueMinor,
paymentToken: charge.paymentToken,
expiresAt,
quote: q,
};
}
/** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */
async applyForCharge(supplementaryChargeId: string) {
const u = await this.prisma.bookingUpgrade.findUnique({ where: { supplementaryChargeId } });
if (!u || u.status !== 'PENDING_PAYMENT') return;
await this.apply(u.id);
}
/** Moves the named passengers into their new seats. The schedule never changes. */
async apply(upgradeId: string) {
const u = await this.prisma.bookingUpgrade.findUnique({ where: { id: upgradeId } });
if (!u) throw new NotFoundException('Upgrade not found');
if (u.status !== 'PENDING_PAYMENT') return u;
const booking = await this.prisma.booking.findUnique({ where: { id: u.bookingId }, include: bookingInclude });
if (!booking) throw new NotFoundException('Booking not found');
const items = u.items as unknown as UpgradeItem[];
const seatById = new Map(booking.seats.map((s) => [s.id, s]));
for (const it of items) {
if (!seatById.has(it.bookingSeatId)) {
throw new BadRequestException('A passenger on this upgrade is no longer on the booking');
}
}
const newTotal = Math.max(0, booking.totalMinor + u.fareDifferenceMinor);
const displayTotal =
booking.displayCurrency && booking.displayCurrency !== 'ETB'
? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any)
: newTotal;
await this.prisma.$transaction(async (tx) => {
await tx.booking.update({
where: { id: booking.id },
data: { totalMinor: newTotal, displayTotalMinor: displayTotal },
});
// Two passes, as the reschedule flow does. Here it is defensive rather than required: the
// schedule is unchanged, so `@@unique([scheduleId, seatId])` can only collide when one
// request upgrades two passengers and the second lands on a seat the first is vacating
// (B: EBC→VIP frees EBC-7 while A: RS→EBC takes it). Parking every row on a per-row-unique
// sentinel first makes the write order irrelevant.
for (const it of items) {
await tx.bookingSeat.update({
where: { id: it.bookingSeatId },
data: { scheduleId: `moving-${it.bookingSeatId}` },
});
}
for (const it of items) {
await tx.bookingSeat.update({
where: { id: it.bookingSeatId },
data: {
seatId: it.newSeatId,
scheduleId: u.scheduleId,
fareMinor: it.newFareMinor,
seatLabelSnapshot: null,
},
});
}
await tx.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: u.requestedBy,
modificationType: 'UPGRADE',
oldData: {
leg: u.leg,
scheduleId: u.scheduleId,
items: items.map((i) => ({
bookingSeatId: i.bookingSeatId, passengerName: i.passengerName,
seatId: i.oldSeatId, seatLabel: i.oldSeatLabel,
coachTypeId: i.oldCoachTypeId, fareMinor: i.oldFareMinor,
})),
},
newData: {
leg: u.leg,
scheduleId: u.scheduleId,
feeMinor: u.feeMinor,
items: items.map((i) => ({
bookingSeatId: i.bookingSeatId, passengerName: i.passengerName,
seatId: i.newSeatId, seatLabel: i.newSeatLabel,
coachTypeId: i.newCoachTypeId, fareMinor: i.newFareMinor,
})),
},
fareAdjustment: u.fareDifferenceMinor,
},
});
await tx.bookingUpgrade.update({
where: { id: u.id },
data: { status: 'APPLIED', appliedAt: new Date() },
});
});
// Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction.
const fresh = await this.prisma.booking.findUnique({
where: { id: booking.id },
include: { seats: true, tickets: { select: { id: true } } },
});
if (fresh) {
try {
await this.seatsService.releaseSeats(fresh.id);
await this.paymentsService.createJourneySegments(fresh as any);
} catch (err) {
this.logger.error(`Upgrade ${u.id}: journey segments failed: ${err instanceof Error ? err.message : err}`);
}
// Old tickets' SYSTEM seat blocks reference ticket ids generate() is about to delete, and
// generate() only clears blocks for the booking's CURRENT seats — the vacated seat is no
// longer among them, so its block would survive.
for (const t of fresh.tickets) {
await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } });
}
try {
await this.ticketsService.generate(fresh.id);
} catch (err) {
this.logger.error(`Upgrade ${u.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`);
}
}
// Unlike reschedule, this upgrade stayed on the SAME schedule — so the booking's original
// seat hold is still in scope and would keep the vacated seat reading HELD on the very train
// still being sold. Clearing it is what puts that seat back on sale.
await this.prisma.seatHold.deleteMany({
where: {
OR: [
{ id: u.holdId ?? '' },
{ scheduleId: u.scheduleId, seatIds: { hasSome: items.map((i) => i.oldSeatId) } },
],
},
});
await this.auditService.log({
userId: u.requestedBy,
action: AUDIT_ACTIONS.UPDATE,
entityType: AUDIT_ENTITIES.Booking,
entityId: booking.id,
oldData: { leg: u.leg, items: items.map((i) => ({ seatId: i.oldSeatId, coachTypeId: i.oldCoachTypeId })) },
newData: {
leg: u.leg,
upgradeId: u.id,
feeMinor: u.feeMinor,
fareDifferenceMinor: u.fareDifferenceMinor,
items: items.map((i) => ({ seatId: i.newSeatId, coachTypeId: i.newCoachTypeId })),
},
});
this.eventEmitter.emit('booking.upgraded', { booking: fresh ?? booking, upgrade: u });
return { ...u, status: 'APPLIED' };
}
/** Cron hook: unpaid upgrades past their payment deadline. The seat hold lapses by itself. */
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingUpgrade.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true, items: true },
});
for (const u of stale) {
await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } });
if (u.supplementaryChargeId) {
await this.prisma.supplementaryCharge.updateMany({
where: { id: u.supplementaryChargeId, status: 'PENDING' },
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (u.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } });
}
}
// After the loop on purpose: the rows are already committed, so a notification failure
// cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors.
for (const s of stale) {
this.eventEmitter.emit('booking.upgrade.expired', { bookingId: s.bookingId, request: s });
}
return stale.length;
}
// ── Internals ────────────────────────────────────────────────────────────
private load(bookingRef: string, user: ActingUser) {
return loadOwnedBooking(this.prisma, bookingRef, user, bookingInclude, 'upgrade it') as Promise<
Prisma.BookingGetPayload<{ include: typeof bookingInclude }>
>;
}
private legsOf(booking: any) {
const legs: any[] = [];
const build = (n: number, scheduleId: string, schedule: any, originStationId: string, destinationStationId: string) => {
const seats = (booking.seats as any[])
.filter((s) => (s.leg ?? 1) === n)
.map((s) => ({
id: s.id,
seatId: s.seatId,
seatLabel: s.seatLabelSnapshot ?? s.seat?.seatNumber ?? null,
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
fareMinor: s.fareMinor,
coachTypeId: s.seat?.coach?.coachTypeId,
}));
if (!seats.length || !schedule) return;
legs.push({ leg: n, scheduleId, schedule, originStationId, destinationStationId, departureAt: schedule.departureAt, seats });
};
build(1, booking.scheduleId, booking.schedule, booking.originStationId, booking.destinationStationId);
if (booking.bookingType === 'ROUND_TRIP') {
build(2, booking.returnScheduleId, booking.returnSchedule, booking.returnOriginStationId, booking.returnDestinationStationId);
}
return legs;
}
/** Resolves the boarding stop's check-in cutoff — the deadline US-17 §1 means by "before check-in". */
private async resolveLegCheckin(leg: any) {
const stopTime = await this.prisma.tripStopTime.findFirst({
where: { scheduleId: leg.scheduleId, stationId: leg.originStationId ?? undefined },
select: { plannedArrivalAt: true, plannedDepartureAt: true },
});
return resolveCheckinCutoff(leg.schedule, stopTime, leg.originStationId);
}
private async legBlockers(booking: any, leg: any, now = new Date()): Promise<string[]> {
const blockers: string[] = [];
if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be upgraded.');
if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be upgraded.');
if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.');
if (leg.schedule?.status !== 'SCHEDULED' || leg.departureAt <= now) blockers.push('This departure is no longer upgradable.');
leg.checkin = await this.resolveLegCheckin(leg);
if (leg.checkin.cutoffAt <= now) {
blockers.push(`Upgrades close ${leg.checkin.checkinMinutes} minutes before departure.`);
}
// One change at a time. Two live supplementary charges could both drive ticket regeneration
// on this booking and interleave unpredictably.
const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
if (pendingUpgrade) blockers.push('An upgrade is already awaiting payment for this booking.');
const pendingReschedule = await this.prisma.bookingReschedule.findFirst({
where: { bookingId: booking.id, status: 'PENDING_PAYMENT' },
});
if (pendingReschedule) blockers.push('A reschedule is awaiting payment for this booking — finish or cancel it first.');
return blockers;
}
/** Fare classes on this schedule that anyone could upgrade into. */
private async targetsFor(leg: any) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId: leg.scheduleId, isOperational: true },
select: { coach: { select: { coachTypeId: true } } },
});
const onBoard = [...new Set(assignments.map((a) => a.coach.coachTypeId))];
if (!onBoard.length) return [];
const policies = await this.prisma.upgradePolicy.findMany({
where: { coachTypeId: { in: onBoard }, isActive: true, isTargetable: true, coachType: NOT_A_FARE_CLASS },
include: { coachType: { select: { id: true, code: true, name: true } } },
orderBy: { rank: 'asc' },
});
return policies.map((p) => ({
coachTypeId: p.coachTypeId,
code: p.coachType.code,
name: p.coachType.name,
rank: p.rank,
feePercent: p.feePercent,
feeMinMinor: p.feeMinMinor,
feeWaived: p.feeWaived,
}));
}
private async buildQuote(booking: any, dto: UpgradeQuoteDto, opts: { skipAvailability?: boolean } = {}) {
const legNo = dto.leg ?? 1;
const leg = this.legsOf(booking).find((l) => l.leg === legNo);
if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`);
const blockers = await this.legBlockers(booking, leg);
const target = await this.prisma.upgradePolicy.findUnique({
where: { coachTypeId: dto.newCoachTypeId },
include: { coachType: { select: { id: true, code: true, name: true, type: true, seatClasses: { where: { isActive: true } } } } },
});
if (!target) throw new NotFoundException('That fare class has no upgrade policy');
if (!target.isActive || !target.isTargetable) blockers.push(`${target.coachType.code} cannot be upgraded to.`);
const onSchedule = await this.prisma.coachAssignment.count({
where: { scheduleId: leg.scheduleId, isOperational: true, coach: { coachTypeId: dto.newCoachTypeId } },
});
if (!onSchedule) blockers.push(`${target.coachType.code} is not available on this train.`);
const seatRows = await this.prisma.seat.findMany({
where: { id: { in: dto.items.map((i) => i.newSeatId) } },
include: { coach: { select: { id: true, coachTypeId: true } } },
});
const seatById = new Map(seatRows.map((s) => [s.id, s]));
if (seatRows.length !== dto.items.length) blockers.push('One or more selected seats do not exist.');
if (new Set(dto.items.map((i) => i.newSeatId)).size !== dto.items.length) blockers.push('Duplicate seats selected.');
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
include: { station: { select: { code: true } } },
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find((s) => s.stationId === leg.originStationId);
const destStop = stopTimes.find((s) => s.stationId === leg.destinationStationId);
if (!originStop || !destStop) blockers.push('This leg\'s route could not be resolved.');
const { nationalityType, nationality } = resolveNationalityProxy(booking.displayCurrency);
const segmentRoute = originStop && destStop ? `${originStop.station.code}-${destStop.station.code}` : undefined;
const bookingSeats = new Map(leg.seats.map((s: any) => [s.id, s]));
const items: UpgradeItem[] = [];
let oldFareMinor = 0;
let newFareMinor = 0;
let feeMinor = 0;
for (const req of dto.items) {
const current: any = bookingSeats.get(req.bookingSeatId);
if (!current) { blockers.push('A selected passenger is not on this leg.'); break; }
const source = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId: current.coachTypeId } });
if (!source || !source.isActive || !source.isUpgradable) {
blockers.push(`${current.passengerName} is in a class that cannot be upgraded.`);
break;
}
if (target.rank <= source.rank) {
blockers.push(`${target.coachType.code} is not an upgrade from ${current.passengerName}'s current class.`);
break;
}
const seat = seatById.get(req.newSeatId);
if (!seat) break; // already reported above
if (seat.coach.coachTypeId !== dto.newCoachTypeId) {
blockers.push('Every selected seat must be in the fare class being upgraded to.');
break;
}
const seatClass = originStop && destStop
? pickSeatClass(target.coachType.seatClasses, seat.bedPosition, nationalityType)
: null;
if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; }
const seatFare = await this.bookingsService.getBaseFare(
leg.scheduleId, seatClass.id, segmentRoute, undefined, nationality,
originStop!.sequence, destStop!.sequence, originStop!.stationId, destStop!.stationId,
);
const currentFare = current.fareMinor ?? 0;
const amounts = computeUpgradeAmounts(target, currentFare, seatFare);
// Refuse rather than credit. A "higher" class pricing below the current seat means the fare
// configuration disagrees with the ladder; handing out a free upgrade would hide that.
if (amounts.fareDifferenceMinor <= 0) {
blockers.push(`${target.coachType.code} is not priced above ${current.passengerName}'s current seat on this route.`);
break;
}
oldFareMinor += currentFare;
newFareMinor += seatFare;
feeMinor += amounts.feeMinor;
items.push({
bookingSeatId: current.id,
passengerName: current.passengerName,
passengerCategory: current.passengerCategory,
oldSeatId: current.seatId,
oldSeatLabel: current.seatLabel,
oldCoachTypeId: current.coachTypeId,
oldSeatClassId: null,
oldFareMinor: currentFare,
newSeatId: seat.id,
newSeatLabel: seat.seatNumber,
newCoachTypeId: seat.coach.coachTypeId,
newSeatClassId: seatClass.id,
newFareMinor: seatFare,
feeMinor: amounts.feeMinor,
fareDifferenceMinor: amounts.fareDifferenceMinor,
});
}
// Availability last, so a bad selection reports the clearer error first.
//
// Skipped when re-quoting inside create(): by then the caller is holding these very seats,
// so this check would see their own hold and refuse the upgrade they just paid to make. The
// hold itself is the stronger guarantee — holdSeats ran assertNoRouteSeatConflict plus the
// hold and journey-segment collision checks, and create() verifies the hold is unexpired,
// for this schedule, and covers exactly these seats.
if (!opts.skipAvailability && !blockers.length && originStop && destStop) {
const free = await this.segmentsService.getFreeSeatIds(
leg.scheduleId,
items.map((i) => i.newSeatId),
stopTimes as any,
originStop.sequence,
destStop.sequence,
legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY,
);
const taken = items.filter((i) => !free.has(i.newSeatId));
if (taken.length) blockers.push('One or more selected seats have just been taken.');
}
const fareDifferenceMinor = newFareMinor - oldFareMinor;
return {
allowed: blockers.length === 0 && items.length === dto.items.length,
blockers: Array.from(new Set(blockers)),
leg: legNo,
scheduleId: leg.scheduleId,
newCoachTypeId: dto.newCoachTypeId,
newCoachTypeCode: target.coachType.code,
newCoachTypeName: target.coachType.name,
checkin: leg.checkin,
items,
oldFareMinor,
newFareMinor,
fareDifferenceMinor,
feeMinor,
amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor),
currency: 'ETB',
policy: { feePercent: target.feePercent, feeMinMinor: target.feeMinMinor, feeWaived: target.feeWaived },
};
}
}

View File

@@ -9,7 +9,7 @@ import {
} from 'recharts';
import { toPng } from 'html-to-image';
import { apiClient } from '@/lib/api-client';
import { financeApi, type FinanceGranularity, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance';
import { financeApi, type FinanceBookingType, type FinanceGranularity, type FinanceRevenueType, type FinanceSummaryFilters, type FinanceTripType } from '@/lib/api/finance';
import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook';
import ActionButton from '@/components/ui/ActionButton';
import Pagination from '@/components/ui/Pagination';
@@ -53,6 +53,29 @@ function tripTypeLabel(tripType: string): string {
return TRIP_TYPE_LABELS[tripType] ?? tripType;
}
// Package = a booking carrying a packageId; regular = ordinary ticket sales. Not the
// ONE_WAY/ROUND_TRIP booking type. Fixed order so each keeps its colour when filtered.
const BOOKING_TYPE_ORDER = ['regular', 'package'] as const;
const BOOKING_TYPE_LABELS: Record<string, string> = {
regular: 'Regular',
package: 'Package',
};
function bookingTypeLabel(bookingType: string): string {
return BOOKING_TYPE_LABELS[bookingType] ?? bookingType;
}
// The fare, and the fees collected after it. Fixed order so each keeps its colour when filtered.
const REVENUE_TYPE_ORDER = ['ticket', 'excess_baggage', 'outstanding', 'other'] as const;
const REVENUE_TYPE_LABELS: Record<string, string> = {
ticket: 'Ticket fare',
excess_baggage: 'Excess baggage',
outstanding: 'Outstanding',
other: 'Other charges',
};
function revenueTypeLabel(revenueType: string): string {
return REVENUE_TYPE_LABELS[revenueType] ?? revenueType;
}
function periodLabel(period: string, granularity: FinanceGranularity): string {
if (granularity === 'monthly') {
return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
@@ -124,6 +147,8 @@ export default function FinanceReportPage() {
const [destinationStationId, setDestinationStationId] = useState('');
const [method, setMethod] = useState('');
const [tripType, setTripType] = useState<'' | FinanceTripType>('');
const [bookingType, setBookingType] = useState<'' | FinanceBookingType>('');
const [revenueType, setRevenueType] = useState<'' | FinanceRevenueType>('');
const [exporting, setExporting] = useState(false);
// Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per
@@ -168,8 +193,10 @@ export default function FinanceReportPage() {
destinationStationId: destinationStationId || undefined,
method: method || undefined,
tripType: tripType || undefined,
bookingType: bookingType || undefined,
revenueType: revenueType || undefined,
}),
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType],
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method, tripType, bookingType, revenueType],
);
const { data: stations = [] } = useQuery<StationOption[]>({
@@ -195,6 +222,8 @@ export default function FinanceReportPage() {
setDestinationStationId('');
setMethod('');
setTripType('');
setBookingType('');
setRevenueType('');
};
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
@@ -209,16 +238,18 @@ export default function FinanceReportPage() {
if (!data) return;
setExporting(true);
try {
const imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }> = {};
const imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage; bookingType?: ChartImage; revenueType?: ChartImage }> = {};
await Promise.all(
currencySections.map(async (section) => {
const [trend, segment, methodImg, tripTypeImg] = await Promise.all([
const [trend, segment, methodImg, tripTypeImg, bookingTypeImg, revenueTypeImg] = await Promise.all([
captureCard(chartRefs.current[`${section.currency}-trend`]),
captureCard(chartRefs.current[`${section.currency}-segment`]),
captureCard(chartRefs.current[`${section.currency}-method`]),
captureCard(chartRefs.current[`${section.currency}-tripType`]),
captureCard(chartRefs.current[`${section.currency}-bookingType`]),
captureCard(chartRefs.current[`${section.currency}-revenueType`]),
]);
imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg };
imagesByCurrency[section.currency] = { trend, segment, method: methodImg, tripType: tripTypeImg, bookingType: bookingTypeImg, revenueType: revenueTypeImg };
}),
);
@@ -234,9 +265,13 @@ export default function FinanceReportPage() {
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
methodLabel: method ? methodLabel(method) : 'All',
tripTypeLabel: tripType ? tripTypeLabel(tripType) : 'All',
bookingTypeLabel: bookingType ? bookingTypeLabel(bookingType) : 'All',
revenueTypeLabel: revenueType ? revenueTypeLabel(revenueType) : 'All',
},
methodLabel,
tripTypeLabel,
bookingTypeLabel,
revenueTypeLabel,
periodLabel,
imagesByCurrency,
});
@@ -256,8 +291,10 @@ export default function FinanceReportPage() {
() => (data?.totals ?? []).slice().sort((a, b) => b.revenueMinor - a.revenueMinor),
[data],
);
const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0);
const hasData = totalBookings > 0;
// Every revenue item, not every booking: a collected baggage fee or underpayment counts here
// too, so this is deliberately no longer labelled "Bookings" in the UI.
const totalItems = totals.reduce((sum, t) => sum + t.bookingCount, 0);
const hasData = totalItems > 0;
// Money is never comparable across currencies, so rather than scoping every chart to
// whichever currency happens to be biggest overall (which would silently drop a
@@ -276,7 +313,9 @@ export default function FinanceReportPage() {
.filter((r) => r.currency === t.currency)
.slice()
.sort((a, b) => b.revenueMinor - a.revenueMinor)
.map((r) => ({ label: r.label, revenue: r.revenueMinor }));
// Minor units → major, same as trendData above. Plotting revenueMinor raw made every
// segment bar read 100× its real value (an ETB 10.9M segment charted as 1.09 billion).
.map((r) => ({ label: r.label, revenue: r.revenueMinor / 100 }));
const methodRows = data.byMethod.filter((r) => r.currency === t.currency);
const methodTotal = methodRows.reduce((sum, r) => sum + r.revenueMinor, 0);
@@ -302,6 +341,30 @@ export default function FinanceReportPage() {
sharePercent: tripTypeTotal > 0 ? (r.revenueMinor / tripTypeTotal) * 100 : 0,
}));
// Regular vs package for this currency, same share rule as the trip-type card.
const bookingTypeRows = data.byBookingType.filter((r) => r.currency === t.currency);
const bookingTypeTotal = bookingTypeRows.reduce((sum, r) => sum + r.revenueMinor, 0);
const bookingTypeBreakdown = bookingTypeRows
.slice()
.sort((a, b) => BOOKING_TYPE_ORDER.indexOf(a.label as any) - BOOKING_TYPE_ORDER.indexOf(b.label as any))
.map((r) => ({
...r,
color: categoricalColor(palette, BOOKING_TYPE_ORDER.indexOf(r.label as any)),
sharePercent: bookingTypeTotal > 0 ? (r.revenueMinor / bookingTypeTotal) * 100 : 0,
}));
// Fare vs the fees collected after it, same share rule as the other breakdown cards.
const revenueTypeRows = data.byRevenueType.filter((r) => r.currency === t.currency);
const revenueTypeTotal = revenueTypeRows.reduce((sum, r) => sum + r.revenueMinor, 0);
const revenueTypeBreakdown = revenueTypeRows
.slice()
.sort((a, b) => REVENUE_TYPE_ORDER.indexOf(a.label as any) - REVENUE_TYPE_ORDER.indexOf(b.label as any))
.map((r) => ({
...r,
color: categoricalColor(palette, REVENUE_TYPE_ORDER.indexOf(r.label as any)),
sharePercent: revenueTypeTotal > 0 ? (r.revenueMinor / revenueTypeTotal) * 100 : 0,
}));
return {
currency: t.currency,
revenueMinor: t.revenueMinor,
@@ -310,6 +373,8 @@ export default function FinanceReportPage() {
segmentData,
methodBreakdown,
tripTypeBreakdown,
bookingTypeBreakdown,
revenueTypeBreakdown,
};
});
}, [data, totals, palette]);
@@ -386,6 +451,24 @@ export default function FinanceReportPage() {
<option value="international">International to/from Djibouti</option>
</select>
</div>
<div>
<label className="label">Booking Type</label>
<select className="input" value={bookingType} onChange={(e) => setBookingType(e.target.value as '' | FinanceBookingType)}>
<option value="">All bookings</option>
<option value="regular">Regular ticket sales</option>
<option value="package">Package travel packages</option>
</select>
</div>
<div>
<label className="label">Revenue Type</label>
<select className="input" value={revenueType} onChange={(e) => setRevenueType(e.target.value as '' | FinanceRevenueType)}>
<option value="">All revenue</option>
<option value="ticket">Ticket fare</option>
<option value="excess_baggage">Excess baggage luggage</option>
<option value="outstanding">Outstanding underpayments</option>
<option value="other">Other charges upgrade, reschedule</option>
</select>
</div>
<div>
<label className="label">Payment Method</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
@@ -410,7 +493,7 @@ export default function FinanceReportPage() {
) : !hasData ? (
<div className="card py-16 text-center text-muted-foreground">
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No paid bookings in this window.</p>
<p>No revenue collected in this window.</p>
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
</div>
) : (
@@ -430,19 +513,19 @@ export default function FinanceReportPage() {
<span className="text-sm font-medium">{t.currency}</span>
<span className="text-right">
<span className="text-sm font-semibold tabular-nums">{formatCurrency(t.revenueMinor, t.currency)}</span>
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{t.bookingCount.toLocaleString()} bookings</span>
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{t.bookingCount.toLocaleString()} items</span>
</span>
</div>
))}
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue items</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{totalBookings.toLocaleString()}</p>
<p className="text-2xl font-bold tabular-nums mt-1">{totalItems.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Across {totals.length} currenc{totals.length === 1 ? 'y' : 'ies'}
</p>
@@ -458,7 +541,7 @@ export default function FinanceReportPage() {
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-foreground">{section.currency}</h2>
<span className="text-xs text-muted-foreground">
{formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} bookings
{formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} items
</span>
</div>
@@ -532,7 +615,7 @@ export default function FinanceReportPage() {
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Trip Type</th>
<th className="text-right font-medium py-2">Bookings</th>
<th className="text-right font-medium py-2">Items</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
@@ -555,6 +638,110 @@ export default function FinanceReportPage() {
</table>
</div>
{/* Regular vs package — package revenue is a booking carrying a packageId; the
legacy standalone PackageBooking table is not counted here, as it never was. */}
<div className="card" ref={setChartRef(`${section.currency}-bookingType`)}>
<h3 className="text-base font-semibold text-foreground">
Revenue by Booking Type <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Share of {section.currency} revenue between ordinary ticket sales and travel packages
</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`${section.currency} revenue by booking type: ${section.bookingTypeBreakdown
.map((b) => `${bookingTypeLabel(b.label)} ${b.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{section.bookingTypeBreakdown.map((b, i) => (
<div
key={b.key}
className="h-full"
style={{ width: `${b.sharePercent}%`, background: b.color, marginRight: i < section.bookingTypeBreakdown.length - 1 ? 2 : 0 }}
title={`${bookingTypeLabel(b.label)}${formatCurrency(b.revenueMinor, b.currency)}`}
/>
))}
</div>
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Booking Type</th>
<th className="text-right font-medium py-2">Items</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{section.bookingTypeBreakdown.map((b) => (
<tr key={b.key}>
<td className="py-2">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: b.color }} aria-hidden="true" />
<span className="text-foreground">{bookingTypeLabel(b.label)}</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{b.bookingCount.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{b.sharePercent.toFixed(1)}%</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(b.revenueMinor, b.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Ticket fare vs the fees collected after it. Charge revenue is new to this
report — it was invisible here until the revenue-type work. */}
<div className="card" ref={setChartRef(`${section.currency}-revenueType`)}>
<h3 className="text-base font-semibold text-foreground">
Revenue by Revenue Type <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Share of {section.currency} revenue between the ticket fare and the fees collected after it
</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`${section.currency} revenue by revenue type: ${section.revenueTypeBreakdown
.map((r) => `${revenueTypeLabel(r.label)} ${r.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{section.revenueTypeBreakdown.map((r, i) => (
<div
key={r.key}
className="h-full"
style={{ width: `${r.sharePercent}%`, background: r.color, marginRight: i < section.revenueTypeBreakdown.length - 1 ? 2 : 0 }}
title={`${revenueTypeLabel(r.label)}${formatCurrency(r.revenueMinor, r.currency)}`}
/>
))}
</div>
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Revenue Type</th>
<th className="text-right font-medium py-2">Items</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{section.revenueTypeBreakdown.map((r) => (
<tr key={r.key}>
<td className="py-2">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: r.color }} aria-hidden="true" />
<span className="text-foreground">{revenueTypeLabel(r.label)}</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{r.bookingCount.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{r.sharePercent.toFixed(1)}%</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(r.revenueMinor, r.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
<div className="card" ref={setChartRef(`${section.currency}-method`)}>
<h3 className="text-base font-semibold text-foreground">
@@ -581,7 +768,7 @@ export default function FinanceReportPage() {
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Method</th>
<th className="text-right font-medium py-2">Bookings</th>
<th className="text-right font-medium py-2">Items</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
@@ -617,7 +804,7 @@ export default function FinanceReportPage() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['Period', 'Segment', 'Type', 'Method', 'Currency', 'Bookings', 'Revenue'].map((h) => (
{['Period', 'Segment', 'Type', 'Booking', 'Revenue Type', 'Method', 'Currency', 'Items', 'Revenue'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
{h}
</th>
@@ -630,6 +817,8 @@ export default function FinanceReportPage() {
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{tripTypeLabel(r.tripType)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{bookingTypeLabel(r.bookingType)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{revenueTypeLabel(r.revenueType)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.currency}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
@@ -638,7 +827,7 @@ export default function FinanceReportPage() {
))}
{pg.paged.length === 0 && (
<tr>
<td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
<td colSpan={9} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
</tr>
)}
</tbody>

View File

@@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [bookingPayWindow, setBookingPayWindow] = useState('120');
const [reschedulePayWindow, setReschedulePayWindow] = useState('120');
const [upgradePayWindow, setUpgradePayWindow] = useState('120');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [boardingWindowHours, setBoardingWindowHours] = useState('4');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
@@ -24,6 +27,9 @@ export default function SettingsPage() {
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.booking_payment_window_minutes) setBookingPayWindow(data.booking_payment_window_minutes);
if (data?.reschedule_payment_window_minutes) setReschedulePayWindow(data.reschedule_payment_window_minutes);
if (data?.upgrade_payment_window_minutes) setUpgradePayWindow(data.upgrade_payment_window_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
@@ -40,6 +46,9 @@ export default function SettingsPage() {
try {
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
booking_payment_window_minutes: bookingPayWindow,
reschedule_payment_window_minutes: reschedulePayWindow,
upgrade_payment_window_minutes: upgradePayWindow,
hold_cutoff_hours_before_departure: holdCutoffHours,
boarding_window_hours_before_departure: boardingWindowHours,
throttle_auth_limit: throttleAuthLimit,
@@ -149,6 +158,44 @@ export default function SettingsPage() {
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Payment Windows (minutes)</h3>
<p className="text-xs text-muted-foreground -mt-4">
How long a payer has before the request expires and the held seat is released. The
check-in cutoff is still the hard limit a longer window can never allow payment after
boarding closes.
</p>
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="booking-pay-window">New booking</label>
<input
id="booking-pay-window"
type="number" min="1" className="input"
value={bookingPayWindow}
onChange={(e) => setBookingPayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay for a new booking before it is auto-cancelled. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="reschedule-pay-window">Reschedule</label>
<input
id="reschedule-pay-window"
type="number" min="1" className="input"
value={reschedulePayWindow}
onChange={(e) => setReschedulePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a reschedule charge. Default: 120.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="upgrade-pay-window">Fare upgrade</label>
<input
id="upgrade-pay-window"
type="number" min="1" className="input"
value={upgradePayWindow}
onChange={(e) => setUpgradePayWindow(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Time to pay a fare-class upgrade. Default: 120.</p>
</div>
</div>
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,26 @@
'use client';
import UpgradePolicyManager from '@/components/upgrade/UpgradePolicyManager';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
/**
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
* can be neither upgraded from nor to. Gated on bookings:view because that is what
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
*/
export default function UpgradePoliciesPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
<p className="text-muted-foreground mt-1">
Which fare classes a passenger may move up to before check-in, and what the change costs
</p>
</div>
<UpgradePolicyManager />
</div>
</PermissionGuard>
);
}

View File

@@ -29,6 +29,7 @@ import {
Briefcase,
Calendar,
CalendarClock,
ArrowUpNarrowWide,
Utensils,
Package,
Moon,
@@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
]
},
{

View File

@@ -0,0 +1,363 @@
'use client';
import { useEffect, useState } from 'react';
import { Edit, Plus, Save, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import {
upgradePolicyApi,
type UpgradePolicyCoachType,
type UpgradePolicyRow,
type UpgradePolicyValues,
} from '@/lib/api';
const EMPTY_POLICY: UpgradePolicyValues = {
rank: 0,
feePercent: 0,
feeMinMinor: 0,
feeWaived: false,
isUpgradable: true,
isTargetable: true,
isActive: true,
};
// Money is entered in ETB and stored in minor units.
const etb = (minor: number) => String(minor / 100);
const toMinor = (value: string) => Math.round(Number(value || 0) * 100);
const feeLabel = (p: UpgradePolicyRow) =>
p.feeWaived
? 'Waived'
: p.feePercent > 0 || p.feeMinMinor > 0
? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}`
: 'Free';
/**
* Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in
* a dialog, the same shape as Reschedule Policies and Coach Management.
*/
export default function UpgradePolicyManager() {
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<UpgradePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<UpgradePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<UpgradePolicyRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const [policies, coachTypes] = await Promise.all([
upgradePolicyApi.list(),
upgradePolicyApi.availableCoachTypes(),
]);
setRows(Array.isArray(policies) ? policies : []);
setAvailable(Array.isArray(coachTypes) ? coachTypes : []);
} catch {
setMessage('Failed to load upgrade policies.');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = () => {
setEditing(null);
setCoachTypeId('');
// Suggest the next free rung rather than 0, which would clash with an existing policy.
setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 });
setFormError('');
setShowModal(true);
};
const openEdit = (row: UpgradePolicyRow) => {
setEditing(row);
setCoachTypeId(row.coachTypeId);
setForm({
rank: row.rank,
feePercent: row.feePercent,
feeMinMinor: row.feeMinMinor,
feeWaived: row.feeWaived,
isUpgradable: row.isUpgradable,
isTargetable: row.isTargetable,
isActive: row.isActive,
});
setFormError('');
setShowModal(true);
};
const setField = (patch: Partial<UpgradePolicyValues>) => setForm((f) => ({ ...f, ...patch }));
const submit = async () => {
if (!editing && !coachTypeId) {
setFormError('Pick a fare class.');
return;
}
setSaving(true);
setFormError('');
try {
if (editing) await upgradePolicyApi.update(editing.coachTypeId, form);
else await upgradePolicyApi.create({ coachTypeId, ...form });
setShowModal(false);
setMessage(editing ? 'Policy updated.' : 'Policy created.');
await load();
} catch (err: any) {
setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
setDeleteBusy(true);
try {
await upgradePolicyApi.remove(deleting.coachTypeId);
setDeleting(null);
setMessage('Policy deleted.');
await load();
} catch {
setMessage('Failed to delete the policy.');
} finally {
setDeleteBusy(false);
}
};
const columns = [
{
key: 'coachType',
label: 'Fare class',
render: (row: UpgradePolicyRow) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> {row.coachType?.name}</span>
</div>
),
},
{
key: 'rank',
label: 'Rank',
render: (row: UpgradePolicyRow) => <span className="font-mono text-sm">{row.rank}</span>,
},
{
key: 'fee',
label: 'Change fee',
render: (row: UpgradePolicyRow) => <span className="text-sm">{feeLabel(row)}</span>,
},
{
key: 'isUpgradable',
label: 'Upgrade from',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isUpgradable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isUpgradable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isTargetable',
label: 'Upgrade to',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isTargetable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isTargetable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Delete',
onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-3xl">
Rank orders the ladder a passenger may only move to a class with a higher rank, on the same train.
Fee = max(fee % × the passenger&apos;s original fare, minimum), read from the class being upgraded
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Upgrade Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No upgrade policies yet — add one to allow fare-class upgrades."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code}${editing.coachType?.name}`}
disabled
/>
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Rank</label>
<input
type="number"
min="0"
className="input"
value={form.rank}
onChange={(e) => setField({ rank: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">Higher beats lower. Must be unique among active policies.</p>
</div>
<div className="space-y-1">
<label className="label">Change fee</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.feeWaived}
onChange={(e) => setField({ feeWaived: e.target.checked })}
/>
Waived charge only the fare difference
</label>
</div>
<div className="space-y-1">
<label className="label">Fee (% of original fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={form.feeWaived}
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={form.feeWaived}
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Upgrade from this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isUpgradable}
onChange={(e) => setField({ isUpgradable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Upgrade to this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isTargetable}
onChange={(e) => setField({ isTargetable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title="Delete upgrade policy"
message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`}
warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected."
confirmText="Delete"
isDanger
isLoading={deleteBusy}
/>
</div>
);
}

View File

@@ -9,6 +9,19 @@ export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
*/
export type FinanceTripType = 'intercity' | 'international';
/**
* Travel-package revenue vs ordinary ticket sales. `package` is a booking carrying a
* `packageId`; `regular` is one without. Unrelated to the ONE_WAY/ROUND_TRIP booking type.
*/
export type FinanceBookingType = 'regular' | 'package';
/**
* Which revenue stream a row came from. `ticket` is the booking fare; the rest are fees
* collected after it — an excess-baggage charge, a recovered underpayment, or any other
* supplementary charge (upgrade, reschedule, and anything added later).
*/
export type FinanceRevenueType = 'ticket' | 'excess_baggage' | 'outstanding' | 'other';
export interface FinanceSummaryFilters {
dateFrom: string;
dateTo: string;
@@ -17,6 +30,8 @@ export interface FinanceSummaryFilters {
destinationStationId?: string;
method?: string;
tripType?: FinanceTripType;
bookingType?: FinanceBookingType;
revenueType?: FinanceRevenueType;
}
export interface FinanceBucketRow {
@@ -25,6 +40,9 @@ export interface FinanceBucketRow {
destinationStationId: string;
segmentLabel: string;
tripType: FinanceTripType;
bookingType: FinanceBookingType;
revenueType: FinanceRevenueType;
/** `UNKNOWN` on charge rows — settling a charge records no payment method. */
method: string;
currency: string;
bookingCount: number;
@@ -46,6 +64,10 @@ export interface FinanceSummaryReport {
dateTo: string;
/** The trip-type filter that was applied, or `null` when every trip is included. */
tripType: FinanceTripType | null;
/** The booking-type filter that was applied, or `null` when every booking is included. */
bookingType: FinanceBookingType | null;
/** The revenue-type filter that was applied, or `null` when every stream is included. */
revenueType: FinanceRevenueType | null;
/** Grand totals, one entry per currency present — never summed across currencies. */
totals: FinanceRollupRow[];
byPeriod: FinanceRollupRow[];
@@ -53,6 +75,10 @@ export interface FinanceSummaryReport {
byMethod: FinanceRollupRow[];
/** Intercity vs international split. One entry per trip type per currency. */
byTripType: FinanceRollupRow[];
/** Regular vs package split. One entry per booking type per currency. */
byBookingType: FinanceRollupRow[];
/** Fare vs baggage/outstanding/other split. One entry per revenue type per currency. */
byRevenueType: FinanceRollupRow[];
rows: FinanceBucketRow[];
}
@@ -64,6 +90,8 @@ function toParams(filters: FinanceSummaryFilters): Record<string, string> {
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
if (filters.method) params.method = filters.method;
if (filters.tripType) params.tripType = filters.tripType;
if (filters.bookingType) params.bookingType = filters.bookingType;
if (filters.revenueType) params.revenueType = filters.revenueType;
return params;
}

View File

@@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues {
coachTypeId: string;
coachType: ReschedulePolicyCoachType;
}
// Fare-class upgrade policy API — one policy per coach type. `rank` orders the ladder; an
// upgrade requires a strictly higher rank. A coach type with no policy can be neither left nor
// entered.
export interface UpgradePolicyValues {
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
isUpgradable: boolean;
isTargetable: boolean;
isActive: boolean;
}
export interface UpgradePolicyCoachType {
id: string;
code: string;
name: string;
type: string;
}
export interface UpgradePolicyRow extends UpgradePolicyValues {
id: string;
coachTypeId: string;
coachType: UpgradePolicyCoachType;
}
export const upgradePolicyApi = {
list: () => apiClient.get<UpgradePolicyRow[]>('/upgrade/policies'),
availableCoachTypes: () =>
apiClient.get<UpgradePolicyCoachType[]>('/upgrade/policies/available-coach-types'),
create: (data: UpgradePolicyValues & { coachTypeId: string }) =>
apiClient.post<UpgradePolicyRow>('/upgrade/policies', data),
update: (coachTypeId: string, data: Partial<UpgradePolicyValues>) =>
apiClient.patch<UpgradePolicyRow>(`/upgrade/policies/${coachTypeId}`, data),
remove: (coachTypeId: string) => apiClient.delete<any>(`/upgrade/policies/${coachTypeId}`),
};
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
availableCoachTypes: () =>

View File

@@ -32,12 +32,14 @@ export interface ChartImage {
export interface FinanceWorkbookInput {
report: FinanceSummaryReport;
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string; tripTypeLabel: string };
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string; tripTypeLabel: string; bookingTypeLabel: string; revenueTypeLabel: string };
methodLabel: (method: string) => string;
tripTypeLabel: (tripType: string) => string;
bookingTypeLabel: (bookingType: string) => string;
revenueTypeLabel: (revenueType: string) => string;
periodLabel: (period: string, granularity: FinanceGranularity) => string;
/** One Trend/Segment/Method/Trip-type image set per currency present — mirrors the on-screen per-currency sections. */
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage }>;
/** One chart image set per currency present — mirrors the on-screen per-currency sections. */
imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage; tripType?: ChartImage; bookingType?: ChartImage; revenueType?: ChartImage }>;
}
function styleHeaderCell(cell: ExcelJS.Cell) {
@@ -161,10 +163,13 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
const { report, filters, imagesByCurrency } = input;
const methodLabel = input.methodLabel;
const tripTypeLabel = input.tripTypeLabel;
const bookingTypeLabel = input.bookingTypeLabel;
const revenueTypeLabel = input.revenueTypeLabel;
const periodLabel = input.periodLabel;
const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor);
const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0);
// Every revenue item, not every booking — a collected baggage fee or underpayment counts too.
const totalItems = totals.reduce((sum, t) => sum + t.bookingCount, 0);
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
@@ -177,7 +182,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
titleBanner(
summary,
'EDR Passenger — Finance Summary',
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Trip type: ${filters.tripTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Trip type: ${filters.tripTypeLabel} · Booking type: ${filters.bookingTypeLabel} · Revenue type: ${filters.revenueTypeLabel} · Generated ${new Date().toLocaleString('en-US')}`,
6,
);
@@ -189,7 +194,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
accent: BRAND_DARK,
}));
const cursorAfterKpis = kpiRow(summary, 4, [
{ label: 'Bookings', value: totalBookings.toLocaleString('en-US'), accent: INK },
{ label: 'Revenue items', value: totalItems.toLocaleString('en-US'), accent: INK },
...revenueCards,
]);
@@ -201,13 +206,15 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
cursor = addImage(wb, summary, images.trend, cursor, `Revenue Trend (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.segment, cursor, `Revenue by Segment (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.tripType, cursor, `Revenue by Trip Type (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.bookingType, cursor, `Revenue by Booking Type (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.revenueType, cursor, `Revenue by Revenue Type (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1;
}
// ── By Period sheet ──────────────────────────────────────────────────────
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
byPeriod.columns = [{ width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2]));
addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Items', 'Revenue'], new Set([1, 2]));
const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key));
periodRows.forEach((p, i) => {
const r = byPeriod.getRow(i + 2);
@@ -225,7 +232,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
// ── By Segment sheet ─────────────────────────────────────────────────────
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
bySegment.columns = [{ width: 34 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Bookings', 'Revenue'], new Set([1, 2]));
addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Items', 'Revenue'], new Set([1, 2]));
report.bySegment.forEach((s, i) => {
const r = bySegment.getRow(i + 2);
r.getCell(1).value = s.label;
@@ -245,7 +252,7 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
// currency's grand total, never a cross-currency sum.
const byTripType = wb.addWorksheet('By Trip Type', { views: [{ state: 'frozen', ySplit: 1 }] });
byTripType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byTripType, 1, ['Trip Type', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
addTableHeader(byTripType, 1, ['Trip Type', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
const tripTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byTripType.forEach((t, i) => {
const r = byTripType.getRow(i + 2);
@@ -264,12 +271,60 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
});
byTripType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── By Booking Type sheet ────────────────────────────────────────────────
// Package = a booking carrying a packageId; regular = ordinary ticket sales. Share is
// against the same currency's grand total, never a cross-currency sum.
const byBookingType = wb.addWorksheet('By Booking Type', { views: [{ state: 'frozen', ySplit: 1 }] });
byBookingType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byBookingType, 1, ['Booking Type', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
const bookingTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byBookingType.forEach((b, i) => {
const r = byBookingType.getRow(i + 2);
const currencyTotal = bookingTypeCurrencyTotal.get(b.currency) ?? 0;
r.getCell(1).value = bookingTypeLabel(b.label);
r.getCell(2).value = b.currency;
r.getCell(3).value = b.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = b.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(b.currency);
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = currencyTotal > 0 ? b.revenueMinor / currencyTotal : 0;
r.getCell(5).numFmt = '0.0%';
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(byBookingType, i + 2, 5, i % 2 === 1);
});
byBookingType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── By Revenue Type sheet ────────────────────────────────────────────────
// Ticket fare vs the fees collected after it (baggage, recovered underpayments, other
// supplementary charges). Share is against the same currency's grand total.
const byRevenueType = wb.addWorksheet('By Revenue Type', { views: [{ state: 'frozen', ySplit: 1 }] });
byRevenueType.columns = [{ width: 26 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byRevenueType, 1, ['Revenue Type', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
const revenueTypeCurrencyTotal = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byRevenueType.forEach((rt, i) => {
const r = byRevenueType.getRow(i + 2);
const currencyTotal = revenueTypeCurrencyTotal.get(rt.currency) ?? 0;
r.getCell(1).value = revenueTypeLabel(rt.label);
r.getCell(2).value = rt.currency;
r.getCell(3).value = rt.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = rt.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(rt.currency);
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = currencyTotal > 0 ? rt.revenueMinor / currencyTotal : 0;
r.getCell(5).numFmt = '0.0%';
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(byRevenueType, i + 2, 5, i % 2 === 1);
});
byRevenueType.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── By Method sheet ──────────────────────────────────────────────────────
// Share is computed against the grand total for that same currency (`totals`), never
// against a sum spanning multiple currencies.
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
byMethod.columns = [{ width: 20 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Items', 'Revenue', 'Share'], new Set([1, 2, 3]));
const totalByCurrency = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byMethod.forEach((m, i) => {
const r = byMethod.getRow(i + 2);
@@ -290,23 +345,25 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
// ── Detail sheet — every row, unpaginated ───────────────────────────────
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
detail.columns = [{ width: 18 }, { width: 34 }, { width: 26 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], new Set([3, 4]));
detail.columns = [{ width: 18 }, { width: 34 }, { width: 26 }, { width: 16 }, { width: 20 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Trip Type', 'Booking Type', 'Revenue Type', 'Payment Method', 'Currency', 'Items', 'Revenue'], new Set([5, 6]));
report.rows.forEach((row, i) => {
const r = detail.getRow(i + 2);
r.getCell(1).value = periodLabel(row.period, report.granularity);
r.getCell(2).value = row.segmentLabel;
r.getCell(3).value = tripTypeLabel(row.tripType);
r.getCell(4).value = methodLabel(row.method);
r.getCell(5).value = row.currency;
r.getCell(6).value = row.bookingCount;
r.getCell(6).alignment = { horizontal: 'right' };
r.getCell(7).value = row.revenueMinor / 100;
r.getCell(7).numFmt = currencyFmt(row.currency);
r.getCell(7).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 7, i % 2 === 1);
r.getCell(4).value = bookingTypeLabel(row.bookingType);
r.getCell(5).value = revenueTypeLabel(row.revenueType);
r.getCell(6).value = methodLabel(row.method);
r.getCell(7).value = row.currency;
r.getCell(8).value = row.bookingCount;
r.getCell(8).alignment = { horizontal: 'right' };
r.getCell(9).value = row.revenueMinor / 100;
r.getCell(9).numFmt = currencyFmt(row.currency);
r.getCell(9).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 9, i % 2 === 1);
});
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } };
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 9 } };
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });

View File

@@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react";
import {
Clock,
ArrowUpCircle,
Users,
CheckCircle2,
AlertCircle,
@@ -351,6 +352,7 @@ function BookingDetailContent() {
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`;
const StatusBadge = () => {
const statusConfig = {
@@ -1070,6 +1072,31 @@ function BookingDetailContent() {
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
</button>
)}
{/* Same gating as Reschedule: hidden from a signed-in viewer who did not book
the trip, because the API refuses them; a guest still gets the sign-in
prompt, since signing in as the booker is what unblocks them. Whether any
higher class actually exists on this train is the upgrade page's call. */}
{bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && (
<button
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? upgradePath
: `/login?redirect=${encodeURIComponent(upgradePath)}`,
)
}
title={
isAuthenticated
? "Move to a higher fare class on the same train"
: "Upgrading needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<ArrowUpCircle className="w-4 h-4" />
{isAuthInitialized && !isAuthenticated ? "Sign in to upgrade" : "Upgrade class"}
</button>
)}
</>
)}
</div>

View File

@@ -0,0 +1,571 @@
"use client";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { AlertCircle, ArrowUpCircle, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import { useAuthStore } from "@/lib/auth-store";
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
type Target = {
coachTypeId: string;
code: string;
name: string;
rank: number;
feePercent: number;
feeMinMinor: number;
feeWaived: boolean;
};
type PassengerOption = {
bookingSeatId: string;
passengerName: string;
passengerCategory: string;
seatId: string;
seatLabel: string | null;
coachTypeId: string;
currentFareMinor: number;
targets: Target[];
};
type LegOption = {
leg: number;
scheduleId: string;
originStationId: string | null;
destinationStationId: string | null;
departureAt: string;
checkinCutoffAt: string | null;
checkinMinutes: number | null;
canUpgrade: boolean;
blockers: string[];
passengers: PassengerOption[];
};
type Options = {
bookingRef: string;
bookingType: string;
legs: LegOption[];
pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null;
};
type QuoteItem = {
bookingSeatId: string;
passengerName: string;
oldSeatLabel: string | null;
newSeatLabel: string | null;
oldFareMinor: number;
newFareMinor: number;
feeMinor: number;
fareDifferenceMinor: number;
};
type Quote = {
allowed: boolean;
blockers: string[];
newCoachTypeCode: string;
items: QuoteItem[];
oldFareMinor: number;
newFareMinor: number;
fareDifferenceMinor: number;
feeMinor: number;
amountDueMinor: number;
};
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
function UpgradePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const ref = searchParams.get("ref") || "";
// Every endpoint here is behind JwtGuard, so a guest deep-linking would otherwise watch the
// options request 401 and land on a message blaming the booking. Send them to sign in and
// bring them back. Waits for isInitialized: the store starts logged-out.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isAuthInitialized = useAuthStore((s) => s.isInitialized);
const needsLogin = isAuthInitialized && !isAuthenticated;
useEffect(() => {
if (!needsLogin) return;
const back = ref ? `/booking/upgrade?ref=${ref}` : "/booking/lookup";
router.replace(`/login?redirect=${encodeURIComponent(back)}`);
}, [needsLogin, ref, router]);
const [legNo, setLegNo] = useState(1);
const [targetCoachTypeId, setTargetCoachTypeId] = useState("");
/** bookingSeatId → chosen seat. Only the passengers in here are upgrading. */
const [picks, setPicks] = useState<Record<string, string>>({});
const [activeBookingSeatId, setActiveBookingSeatId] = useState<string | null>(null);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["upgrade-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/upgrade`),
enabled: !!ref && isAuthInitialized && isAuthenticated,
retry: false,
});
const leg = useMemo(
() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0],
[options, legNo],
);
// Every class anyone on this leg could move up to, de-duplicated for the chooser.
const targets = useMemo(() => {
const byId = new Map<string, Target>();
for (const p of leg?.passengers ?? []) for (const t of p.targets) byId.set(t.coachTypeId, t);
return [...byId.values()].sort((a, b) => a.rank - b.rank);
}, [leg]);
const target = targets.find((t) => t.coachTypeId === targetCoachTypeId) ?? null;
const resetSelection = () => {
setPicks({});
setActiveBookingSeatId(null);
setSelectedCoach(null);
setError(null);
};
// Switching leg or target invalidates every seat already picked — they belong to a coach that
// is no longer being shown.
useEffect(() => {
resetSelection();
}, [legNo, targetCoachTypeId]);
const { data: seatMap, isLoading: loadingSeats } = useQuery<any>({
queryKey: ["upgrade-seatmap", leg?.scheduleId, targetCoachTypeId, leg?.originStationId, leg?.destinationStationId],
queryFn: async () => {
const res: any = await apiClient.get(
`/seats/seatmap/${leg!.scheduleId}?coachTypeId=${targetCoachTypeId}` +
`&journeyDirection=${legNo === 2 ? "RETURN" : "ONE_WAY"}` +
`&originStationId=${leg!.originStationId}&destinationStationId=${leg!.destinationStationId}`,
);
return res?.data || res;
},
enabled: !!leg?.scheduleId && !!targetCoachTypeId,
});
const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]);
const autoExpandedFor = useRef<string | null>(null);
useEffect(() => {
if (!targetCoachTypeId || coaches.length === 0) return;
if (autoExpandedFor.current === targetCoachTypeId) return;
autoExpandedFor.current = targetCoachTypeId;
setSelectedCoach(coaches[0].id);
}, [targetCoachTypeId, coaches]);
/** Passengers eligible for the chosen target, in the API's own order. */
const eligible = useMemo(
() => (leg?.passengers ?? []).filter((p) => p.targets.some((t) => t.coachTypeId === targetCoachTypeId)),
[leg, targetCoachTypeId],
);
// Someone must be "active" for a seat click to mean anything. Without this the seat map looks
// fully interactive but every click is a silent no-op until a passenger row is clicked first —
// and on a single-passenger booking there is nothing obvious to click.
useEffect(() => {
if (!targetCoachTypeId || eligible.length === 0) return;
setActiveBookingSeatId((current) => {
if (current && eligible.some((p) => p.bookingSeatId === current)) return current;
return eligible[0].bookingSeatId;
});
}, [targetCoachTypeId, eligible]);
const items = useMemo(
() =>
eligible
.filter((p) => picks[p.bookingSeatId])
.map((p) => ({ bookingSeatId: p.bookingSeatId, newSeatId: picks[p.bookingSeatId] })),
[eligible, picks],
);
const quoteBody = leg && targetCoachTypeId && items.length > 0
? { leg: leg.leg, newCoachTypeId: targetCoachTypeId, items }
: null;
const { data: quote, isFetching: quoting } = useQuery<Quote>({
queryKey: ["upgrade-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/bookings/${ref}/upgrade/quote`, quoteBody),
enabled: !!quoteBody,
});
const confirm = useMutation({
mutationFn: async () => {
// Goes through the upgrade module rather than /seats/hold directly: an upgrade holds on
// the SAME schedule the booking already occupies, so a retry collides with the caller's
// own abandoned attempt. The server clears those first, and derives the schedule and
// stations from the booking instead of trusting us.
const hold: any = await apiClient.post(`/bookings/${ref}/upgrade/hold`, {
leg: leg!.leg,
seatIds: items.map((it) => it.newSeatId),
});
return apiClient.post<any>(`/bookings/${ref}/upgrade`, { ...quoteBody, holdId: hold.holdId || hold.id });
},
onSuccess: (res) => {
if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`);
else setDone({ status: res.status });
},
onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not upgrade"),
});
const seatOwner = (seatId: string) =>
Object.entries(picks).find(([, sid]) => sid === seatId)?.[0] ?? null;
const handleSeatToggle = (seatId: string) => {
const owner = seatOwner(seatId);
if (owner && owner !== activeBookingSeatId) return; // already another passenger's pick
// Fall back to the first passenger still without a seat, so a click is never swallowed.
const forPassenger =
activeBookingSeatId ?? eligible.find((p) => !picks[p.bookingSeatId])?.bookingSeatId;
if (!forPassenger) return;
setPicks((prev) => {
const next = { ...prev };
if (next[forPassenger] === seatId) {
delete next[forPassenger];
return next;
}
next[forPassenger] = seatId;
// Move to the next passenger still without a seat, so a multi-passenger upgrade can be
// filled by clicking straight down the coach — same behaviour as /booking/seats.
const nextUnassigned = eligible.find((p) => p.bookingSeatId !== forPassenger && !next[p.bookingSeatId]);
if (nextUnassigned) setActiveBookingSeatId(nextUnassigned.bookingSeatId);
return next;
});
};
const labelForSeat = (seatId: string) => {
const seat = coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId);
return seat ? buildSeatLabel(seat) : "";
};
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return (
<Shell>
<p className="text-red-600">
{(optionsError as any)?.response?.data?.message || "This booking cannot be upgraded."}
</p>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade confirmed</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>
View booking
</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Upgrade awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
An upgrade of {etb(options.pending.amountDueMinor)} is waiting to be paid
{options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}.
Your new seats are held until then.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>
Pay now
</button>
)}
</div>
</Shell>
);
}
const Summary = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Upgrade summary
</h2>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Journey</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")}
</div>
{leg.checkinCutoffAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")}
</div>
)}
</div>
{target && (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Upgrading to</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{target.code} {target.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0)
? "none"
: `${target.feePercent}% (min ${etb(target.feeMinMinor)})`}
</div>
</div>
)}
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
{eligible.map((p) => (
<div key={p.bookingSeatId} className="flex justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[55%]">{p.passengerName}</span>
<span className={picks[p.bookingSeatId] ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{picks[p.bookingSeatId]
? `${p.seatLabel ?? "seat"}${labelForSeat(picks[p.bookingSeatId])}`
: "Not upgrading"}
</span>
</div>
))}
</div>
{!quoteBody ? (
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
Choose a class and a seat for each passenger you want to upgrade.
</p>
) : quoting ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : quote ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
<Row label="Current fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row label="Fare difference" value={etb(Math.max(0, quote.fareDifferenceMinor))} />
<Row label="Change fee" value={etb(quote.feeMinor)} />
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
</div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2 text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{error && <div className="text-red-600 text-xs">{error}</div>}
<button
className="btn-primary w-full mt-1"
disabled={!quote.allowed || confirm.isPending}
onClick={() => { setError(null); confirm.mutate(); }}
>
{confirm.isPending ? "Processing..." : `Continue to payment · ${etb(quote.amountDueMinor)}`}
</button>
</div>
) : null}
</div>
);
return (
<Shell wide>
<button
onClick={() => router.push(`/booking/detail?ref=${ref}`)}
className="flex items-center gap-1 text-sm text-gray-500 mb-4"
>
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Upgrade {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Move to a higher fare class on the same train. Each passenger can be upgraded on their own.
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button
key={l.leg}
onClick={() => setLegNo(l.leg)}
className={`px-4 py-2 rounded-lg border text-sm ${
legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"
}`}
>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{!leg.canUpgrade && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>
{leg.blockers.length
? leg.blockers.map((b) => <div key={b}>{b}</div>)
: <div>No higher fare class is available on this train.</div>}
</div>
</div>
)}
{leg.canUpgrade && (
<div className="lg:grid lg:grid-cols-3 lg:gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
{/* Step 1 — class */}
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">Choose a class</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{targets.map((t) => (
<button
key={t.coachTypeId}
onClick={() => setTargetCoachTypeId(t.coachTypeId)}
className={`rounded-xl border p-4 text-left ${
targetCoachTypeId === t.coachTypeId
? "border-primary bg-primary/5"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300"
}`}
>
<div className="font-semibold text-gray-900 dark:text-white">{t.name}</div>
<div className="text-xs text-gray-500">
{t.code} ·{" "}
{t.feeWaived || (t.feePercent === 0 && t.feeMinMinor === 0)
? "no change fee"
: `${t.feePercent}% change fee`}
</div>
</button>
))}
</div>
{/* Step 2 — who, and which seat */}
{targetCoachTypeId && (
<>
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
Who is upgrading? ({items.length}/{eligible.length})
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep
their current seat.
</p>
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{eligible.map((p) => {
const isActive = activeBookingSeatId === p.bookingSeatId;
const picked = picks[p.bookingSeatId];
return (
<button
key={p.bookingSeatId}
type="button"
// Always selects, never clears: with nobody active every seat click is
// silently ignored, which reads as a broken seat map.
onClick={() => setActiveBookingSeatId(p.bookingSeatId)}
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
} hover:bg-gray-50 dark:hover:bg-gray-800/60`}
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
picked
? "bg-[rgb(20,113,76)] text-white"
: isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`}
>
<ArrowUpCircle className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
{p.passengerName}
</span>
<span className="text-[10px] text-gray-400">
now in seat {p.seatLabel ?? "—"}
</span>
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
picked ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{picked ? `Seat ${labelForSeat(picked)}` : isActive ? "Pick a seat" : "Not upgrading"}
</span>
</button>
);
})}
</div>
{loadingSeats ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={(id) => !!activeBookingSeatId && picks[activeBookingSeatId] === id}
isSeatAssignedToOther={(id) => {
const owner = seatOwner(id);
return !!owner && owner !== activeBookingSeatId;
}}
onSeatToggle={handleSeatToggle}
emptyLabel="No seats of that class on this train."
/>
)}
</>
)}
</div>
<div className="lg:hidden">
<Summary />
</div>
</div>
<div className="hidden lg:block">
<div className="sticky top-6 max-h-[calc(100vh-3rem)] overflow-y-auto">
<Summary />
</div>
</div>
</div>
)}
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-gray-700 dark:text-gray-300">
<span>{label}</span>
<span>{value}</span>
</div>
);
}
function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
{wide ? (
<div className="max-w-6xl mx-auto">{children}</div>
) : (
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{children}
</div>
)}
</div>
</div>
);
}
export default function UpgradePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<UpgradePageContent />
</Suspense>
);
}

View File

@@ -277,7 +277,10 @@ function LoginContent() {
);
const heading = {
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
identifier: {
title: 'Sign in or create account',
subtitle: "Enter your phone number or email — we'll sign you in, or set up a new account",
},
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },

View File

@@ -10,6 +10,7 @@ import {
ChevronLeft,
ChevronRight,
Clock,
ArrowUpCircle,
Eye,
CreditCard,
RefreshCw,
@@ -63,29 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) {
interface RowActions {
canReschedule: boolean;
rescheduleBlocker: string | null;
canUpgrade: boolean;
upgradeBlocker: 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.
* The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and
* upgrade share it because the booking-shape rules and the ownership check are identical — only
* the wording differs, hence the verb.
*
* The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train,
* seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This
* only avoids sending the customer somewhere certain to reject them. The phone test matches the
* API's own ownership check (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';
// Both forms are needed: "can be rescheduled" but "can reschedule it".
type Verbs = { past: string; base: string };
const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' };
const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' };
return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
let reason: ((v: Verbs) => string) | null = null;
if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`;
else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`;
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
reason = (v) => `Transit bookings cannot be ${v.past} online`;
else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded';
// Both APIs apply a cutoff measured against departure, so a departed trip is always rejected.
// Say so here instead of sending them to a page that refuses.
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
reason = () => 'This trip has already departed';
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
reason = (v) => `Only the person who made this booking can ${v.base} it`;
const rescheduleBlocker = reason ? reason(RESCHEDULE) : null;
const upgradeBlocker = reason ? reason(UPGRADE) : null;
return {
canReschedule: rescheduleBlocker === null,
rescheduleBlocker,
canUpgrade: upgradeBlocker === null,
upgradeBlocker,
isPendingPayment,
};
}
/**
@@ -118,6 +142,7 @@ export default function MyBookingsTable() {
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`);
const cardClass =
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
@@ -264,6 +289,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
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"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</td>
</tr>
@@ -340,6 +379,20 @@ export default function MyBookingsTable() {
Reschedule
</button>
)}
{!actions.isPendingPayment && (
<button
onClick={() => openUpgrade(b)}
disabled={!actions.canUpgrade}
title={
actions.upgradeBlocker ??
'Move to a higher fare class on the same train'
}
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"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
Upgrade
</button>
)}
</div>
</div>
);