mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 20:03:39 +00:00
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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'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 train’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 }]);
|
||||
}
|
||||
@@ -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, 0–100; 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, 0–100. */
|
||||
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";
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ?? [];
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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];
|
||||
|
||||
144
apps/edr-passenger-api/src/common/utils/booking-change.utils.ts
Normal file
144
apps/edr-passenger-api/src/common/utils/booking-change.utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
94
apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts
Normal file
94
apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts
Normal 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;
|
||||
}
|
||||
46
apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts
Normal file
46
apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts
Normal 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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
874
apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts
Normal file
874
apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal file
571
apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user