mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
Implement clearance-first booking flow and completion process for customs contracts
This commit is contained in:
@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Input set has two required docs.
|
// Input set has two required docs. Non-customs bookings resolve to the
|
||||||
|
// ONE_TIME self-clearance document set.
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'clearance_import_container_without_customs',
|
code: 'contract_clearance_selfclear_import_container',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', isRequired: true },
|
{ fileKey: 'packing_list', isRequired: true },
|
||||||
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'clearance_import_container_without_customs',
|
code: 'contract_clearance_selfclear_import_container',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
|
|||||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||||
'clearance_import_container_with_customs',
|
'clearance_import_container_with_customs',
|
||||||
);
|
);
|
||||||
|
// Non-customs bookings self-clear with the same document set a ONE_TIME
|
||||||
|
// self-clear contract uses.
|
||||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||||
'clearance_import_container_without_customs',
|
'contract_clearance_selfclear_import_container',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
|
|||||||
'clearance_export_bulk_with_customs',
|
'clearance_export_bulk_with_customs',
|
||||||
);
|
);
|
||||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||||
'clearance_export_bulk_without_customs',
|
'contract_clearance_selfclear_export_bulk',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ export function clearanceSettingCode(
|
|||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
||||||
return `clearance_${op}_${freight}_${customs}`;
|
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
||||||
|
// contract uses (customs declaration, release permit, …) — not the
|
||||||
|
// GL-oriented booking sets.
|
||||||
|
if (!includesCustoms) {
|
||||||
|
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||||
|
}
|
||||||
|
return `clearance_${op}_${freight}_with_customs`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
||||||
|
|||||||
@@ -700,6 +700,16 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
bookingsRepository.findBatchPoolByCorridorDay
|
bookingsRepository.findBatchPoolByCorridorDay
|
||||||
.mockResolvedValueOnce([waiting])
|
.mockResolvedValueOnce([waiting])
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard and reserve()'s idempotency guard both re-read the
|
||||||
|
// booking fresh — answer with the matching row, not the paidBooking default
|
||||||
|
// (which would make the guard rescue-allocate the lapsed reservation).
|
||||||
|
const byId: Record<string, Booking> = { lapsed, waiting };
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
byId[opts?.where?.id ?? ''] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
await service.settleDueReservations(trainId);
|
await service.settleDueReservations(trainId);
|
||||||
|
|
||||||
@@ -725,6 +735,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
||||||
});
|
});
|
||||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard re-reads the booking fresh — answer with the
|
||||||
|
// (unpaid) lapsed row, not the paidBooking default.
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
opts?.where?.id === 'lapsed' ? lapsed : null,
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
service.settleDueReservations(trainId),
|
service.settleDueReservations(trainId),
|
||||||
@@ -733,6 +751,37 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
|
|
||||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never expires a reservation whose payment landed — allocates it instead', async () => {
|
||||||
|
const latePaid = booking('late-paid', 50, {
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
paymentDeadline: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
bookingsRepository.findReservedForSchedule
|
||||||
|
.mockResolvedValueOnce([latePaid])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// The payment webhook flipped paymentStatus between the settle's list
|
||||||
|
// read and expire()'s fresh re-read — the deadline had already passed.
|
||||||
|
dataSource
|
||||||
|
.getRepository()
|
||||||
|
.findOne.mockImplementation(
|
||||||
|
async (opts: { where?: { id?: string } }) =>
|
||||||
|
opts?.where?.id === 'late-paid'
|
||||||
|
? { ...latePaid, paymentStatus: 'PAID' }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.settleDueReservations(trainId);
|
||||||
|
|
||||||
|
// Money was taken → the booking boards. Never expired.
|
||||||
|
expect(notifier.expired).not.toHaveBeenCalled();
|
||||||
|
expect(notifier.secured).toHaveBeenCalledTimes(1);
|
||||||
|
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||||
|
[{ trainScheduleId: trainId, bookingId: 'late-paid' }],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
relations: { company: true },
|
relations: { company: true },
|
||||||
});
|
});
|
||||||
if (!booking?.trainScheduleId) return;
|
if (!booking) return;
|
||||||
|
if (!booking.trainScheduleId) {
|
||||||
|
// A paid booking with no train is money taken and nothing boarding —
|
||||||
|
// scream so staff pin it to a schedule manually (batch board / assign).
|
||||||
|
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||||
|
this.logger.error(
|
||||||
|
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
||||||
|
`its reservation was likely expired before the payment landed. ` +
|
||||||
|
`Assign it to a schedule manually from the batch board.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isBatchPaid =
|
const isBatchPaid =
|
||||||
booking.status === "SELECTED_FOR_BATCH" ||
|
booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
@@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||||
|
* `reason` picks the customer message: 'payment' (pay window lapsed) or
|
||||||
|
* 'no-capacity' (no train on the chosen day could take the booking).
|
||||||
|
*
|
||||||
|
* PAID GUARD: a booking whose payment has landed is never expired — money was
|
||||||
|
* taken, so it boards, even when the webhook arrived after the deadline or the
|
||||||
|
* settle read a stale row. It allocates onto the train it was selected for; if
|
||||||
|
* the wagon planner then finds no physical wagon, the booking stays linked and
|
||||||
|
* staff assign wagons manually. Consolidated bookings are exempt from the
|
||||||
|
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
|
||||||
|
* pair decision.
|
||||||
*/
|
*/
|
||||||
private async expire(booking: Booking): Promise<void> {
|
private async expire(
|
||||||
|
booking: Booking,
|
||||||
|
reason: "payment" | "no-capacity" = "payment",
|
||||||
|
): Promise<void> {
|
||||||
|
if (!booking.consolidationPartnerId) {
|
||||||
|
const fresh = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: booking.id }, relations: { company: true } });
|
||||||
|
const paid =
|
||||||
|
fresh != null &&
|
||||||
|
(fresh.paymentStatus === "PAID" || fresh.status === "PAID");
|
||||||
|
const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId;
|
||||||
|
if (paid && paidScheduleId) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
|
||||||
|
`landed; allocating on schedule ${paidScheduleId} instead`,
|
||||||
|
);
|
||||||
|
await this.allocate(paidScheduleId, fresh, "paid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const freedScheduleId = booking.trainScheduleId;
|
const freedScheduleId = booking.trainScheduleId;
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: null,
|
trainScheduleId: null,
|
||||||
@@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||||
// source-agnostic.
|
// source-agnostic.
|
||||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
||||||
this.notifier.expired(booking);
|
if (reason === "no-capacity") {
|
||||||
|
this.notifier.expiredNoCapacity(booking);
|
||||||
|
} else {
|
||||||
|
this.notifier.expired(booking);
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
|
`[BATCH] EXPIRED ${booking.reference} — ` +
|
||||||
`wagons back to the pool for top-up`,
|
(reason === "no-capacity"
|
||||||
|
? "no train on its day had capacity left"
|
||||||
|
: "payment window passed; freed its wagons back to the pool for top-up"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-of-day sweep: once a schedule's window cycle concludes and NO other
|
||||||
|
* train on the same route-day can still run a cycle, the waiting pool for
|
||||||
|
* that day is dead — a FULLY_EXECUTED booking left in it would wait forever.
|
||||||
|
* Expire every leftover commercial booking and tell the customers to rebook
|
||||||
|
* another day. Government bookings are never auto-expired (they preempt).
|
||||||
|
* Returns how many bookings were expired.
|
||||||
|
*/
|
||||||
|
async expireLeftoverDayPool(scheduleId: string): Promise<number> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (!schedule?.scheduledDepartureDate) return 0;
|
||||||
|
const day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
const group: RouteDayGroup = {
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Another train on this route-day that can still take bookings keeps the
|
||||||
|
// pool alive — when IT concludes, its own sweep runs this check again.
|
||||||
|
const siblings = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const anotherTrainStillOpen = siblings.some(
|
||||||
|
(s) =>
|
||||||
|
s.id !== schedule.id &&
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === day &&
|
||||||
|
s.windowPhase !== "DONE" &&
|
||||||
|
s.bookingWindowStatus !== "FULL",
|
||||||
|
);
|
||||||
|
if (anotherTrainStillOpen) return 0;
|
||||||
|
|
||||||
|
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||||
|
const pool = corridorYards.length
|
||||||
|
? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day)
|
||||||
|
: await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||||
|
group.originYardId,
|
||||||
|
group.destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
const leftovers = pool.filter((b) => !b.isGovernment);
|
||||||
|
for (const booking of leftovers) {
|
||||||
|
await this.expire(booking, "no-capacity");
|
||||||
|
}
|
||||||
|
if (leftovers.length) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` +
|
||||||
|
`expired ${leftovers.length} waiting booking(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return leftovers.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
|||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
@@ -139,7 +140,7 @@ export class BookingJourneyService {
|
|||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedule_bookings',
|
TrainScheduleBooking,
|
||||||
'tsb',
|
'tsb',
|
||||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
@@ -218,8 +219,10 @@ export class BookingJourneyService {
|
|||||||
const bookings = await this.dataSource
|
const bookings = await this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
|
// Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
|
||||||
|
// an alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedule_bookings',
|
TrainScheduleBooking,
|
||||||
'tsb',
|
'tsb',
|
||||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
@@ -350,7 +353,7 @@ export class BookingJourneyService {
|
|||||||
.createQueryBuilder('alloc')
|
.createQueryBuilder('alloc')
|
||||||
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.train_schedules',
|
TrainSchedule,
|
||||||
'schedule',
|
'schedule',
|
||||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||||
{ scheduleId },
|
{ scheduleId },
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ export class BookingNotifierService {
|
|||||||
this.inApp(b, 'Payment window expired', msg);
|
this.inApp(b, 'Payment window expired', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every train on the booking's chosen day filled up (or no further train runs)
|
||||||
|
* before the waiting list reached this booking — it expired unplaced. HIGH so
|
||||||
|
* the customer hears about it by email/SMS and rebooks another day.
|
||||||
|
*/
|
||||||
|
expiredNoCapacity(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` +
|
||||||
|
`is full and no other train is scheduled that day. The booking has expired — ` +
|
||||||
|
`please rebook for another day. No re-approval is needed.`;
|
||||||
|
void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)');
|
||||||
|
this.inApp(b, 'No capacity — booking expired', msg, {
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
scheduleFull(b: Booking): void {
|
scheduleFull(b: Booking): void {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
isScheduleFull: jest.Mock;
|
isScheduleFull: jest.Mock;
|
||||||
hasLiveReservations: jest.Mock;
|
hasLiveReservations: jest.Mock;
|
||||||
refreshWindowStatus: jest.Mock;
|
refreshWindowStatus: jest.Mock;
|
||||||
|
expireLeftoverDayPool: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||||
@@ -73,6 +74,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
// No reservation is mid-pay-window by default, so the cycle concludes.
|
// No reservation is mid-pay-window by default, so the cycle concludes.
|
||||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
|
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
trainSchedulesRepository = {
|
trainSchedulesRepository = {
|
||||||
findById: jest.fn().mockResolvedValue(null),
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
@@ -186,6 +188,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
||||||
expect(s.windowPhase).toBe('DONE');
|
expect(s.windowPhase).toBe('DONE');
|
||||||
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
||||||
|
// The day's leftover waiting list is swept once this train is done.
|
||||||
|
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
||||||
@@ -210,6 +214,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
});
|
});
|
||||||
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
||||||
expect(s.windowPhase).toBe('DONE');
|
expect(s.windowPhase).toBe('DONE');
|
||||||
|
// No further train can run for this day → leftover waiting list is swept.
|
||||||
|
expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no transition fires before its deadline (idempotent tick)', async () => {
|
it('no transition fires before its deadline (idempotent tick)', async () => {
|
||||||
|
|||||||
@@ -357,6 +357,10 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||||
);
|
);
|
||||||
|
// This train is done. If no other train on the route-day can still take
|
||||||
|
// the waiting list, those bookings have nowhere to go — expire + notify
|
||||||
|
// them now instead of leaving them FULLY_EXECUTED forever.
|
||||||
|
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +394,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
||||||
`departure — window DONE`,
|
`departure — window DONE`,
|
||||||
);
|
);
|
||||||
|
// No further cycle on this train. Same sweep as the FULL branch: if no
|
||||||
|
// sibling train can still take the day's waiting list, expire + notify.
|
||||||
|
await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
|||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||||
|
import { Company } from '../companies/entities/company.entity';
|
||||||
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
@@ -3651,23 +3655,25 @@ export class WarehouseInventoryService {
|
|||||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
.leftJoinAndSelect('inv.yard', 'yard')
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
.leftJoinAndSelect('inv.zone', 'zone')
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
|
||||||
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
|
// alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
|
.leftJoin(Booking, 'booking', 'booking.id = inv.booking_id')
|
||||||
|
.leftJoin(Company, 'company', 'company.id = booking.company_id')
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'freight.containers',
|
Container,
|
||||||
'container',
|
'container',
|
||||||
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
||||||
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
|
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
|
||||||
AND container.deleted_at IS NULL`,
|
AND container.deleted_at IS NULL`,
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
'freight.cargoes',
|
Cargo,
|
||||||
'cargo',
|
'cargo',
|
||||||
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
||||||
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
|
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
|
||||||
AND cargo.deleted_at IS NULL`,
|
AND cargo.deleted_at IS NULL`,
|
||||||
)
|
)
|
||||||
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
.leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||||||
.addSelect('booking.reference', 'b_reference')
|
.addSelect('booking.reference', 'b_reference')
|
||||||
.addSelect('company.name', 'c_name')
|
.addSelect('company.name', 'c_name')
|
||||||
.addSelect('container.container_number', 'ct_number')
|
.addSelect('container.container_number', 'ct_number')
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { Route } from '../routes/entities/route.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +52,11 @@ export class WarehouseSchedulingAdapterService {
|
|||||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
.leftJoinAndSelect('inv.yard', 'yard')
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
.leftJoinAndSelect('inv.zone', 'zone')
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
|
||||||
|
// alias.property path ("freight" alias was not found) — runtime 500.
|
||||||
|
.innerJoin(Booking, 'booking', 'booking.id = inv.booking_id')
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
'freight.routes',
|
Route,
|
||||||
'route',
|
'route',
|
||||||
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
||||||
{ routeId },
|
{ routeId },
|
||||||
|
|||||||
@@ -198,18 +198,17 @@ export default function GlCreateBookingForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Next future window across all routes, used for the "next window" notice —
|
// Next future window across all routes, used for the "next window" notice —
|
||||||
// the train dispatching soonest among those not yet open, matching the
|
// the next moment booking OPENS (chronological), which may belong to a
|
||||||
// departure-date ordering of the window cards.
|
// later-departing train. Departure-first ordering here named the soonest
|
||||||
|
// train's later opening as "next" while another lane opened earlier.
|
||||||
const nextWindow = useMemo(() => {
|
const nextWindow = useMemo(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return (bookingWindows ?? [])
|
return (bookingWindows ?? [])
|
||||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||||
.sort((a, b) => {
|
.sort(
|
||||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
(a, b) =>
|
||||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||||
if (da !== db) return da - db;
|
)[0];
|
||||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
|
||||||
})[0];
|
|
||||||
}, [bookingWindows]);
|
}, [bookingWindows]);
|
||||||
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Button, Group, type ButtonProps } from "@mantine/core";
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
type ButtonProps,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -57,7 +64,9 @@ export function ContractCustomerAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "pay") {
|
if (action.type === "pay") {
|
||||||
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
return (
|
||||||
|
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "initiate") {
|
if (action.type === "initiate") {
|
||||||
@@ -118,6 +127,7 @@ export function InitiateBookingButton({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -137,6 +147,7 @@ export function InitiateBookingButton({
|
|||||||
toast.success(
|
toast.success(
|
||||||
"Booking initiated — upload your clearance documents to start the review.",
|
"Booking initiated — upload your clearance documents to start the review.",
|
||||||
);
|
);
|
||||||
|
setConfirmOpen(false);
|
||||||
navigate(`/bookings/${booking.id}`);
|
navigate(`/bookings/${booking.id}`);
|
||||||
},
|
},
|
||||||
onError: (e: Error) =>
|
onError: (e: Error) =>
|
||||||
@@ -144,37 +155,87 @@ export function InitiateBookingButton({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
size={size}
|
<Modal
|
||||||
radius="md"
|
opened={confirmOpen}
|
||||||
h={listStyle ? 34 : undefined}
|
onClose={() => {
|
||||||
variant="filled"
|
if (!mutation.isPending) setConfirmOpen(false);
|
||||||
color="edr-green"
|
}}
|
||||||
fullWidth={fullWidth}
|
centered
|
||||||
leftSection={<Icon size={15} />}
|
radius="lg"
|
||||||
loading={mutation.isPending}
|
size="md"
|
||||||
onClick={(e) => {
|
closeOnClickOutside={!mutation.isPending}
|
||||||
e.stopPropagation();
|
closeOnEscape={!mutation.isPending}
|
||||||
mutation.mutate();
|
withCloseButton={!mutation.isPending}
|
||||||
}}
|
title={
|
||||||
styles={
|
<Group gap={10} wrap="nowrap">
|
||||||
listStyle
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
? {
|
<Icon size={18} />
|
||||||
root: {
|
</ThemeIcon>
|
||||||
fontWeight: 600,
|
<Text fw={700}>Initiate a new booking?</Text>
|
||||||
fontSize: 13,
|
</Group>
|
||||||
paddingInline: 14,
|
}
|
||||||
whiteSpace: "nowrap" as const,
|
>
|
||||||
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
<Text size="sm" c="dimmed">
|
||||||
},
|
This creates a new shipment booking under contract{" "}
|
||||||
}
|
<Text span fw={700} c="#10202F">
|
||||||
: undefined
|
{contract.reference}
|
||||||
}
|
</Text>
|
||||||
fw={listStyle ? undefined : 700}
|
. You'll upload the clearance documents next, and the shipment
|
||||||
fz={listStyle ? undefined : 13}
|
quantity is drawn down from your contract's reserved capacity.
|
||||||
>
|
</Text>
|
||||||
{label}
|
<Group justify="flex-end" gap="sm" mt="lg">
|
||||||
</Button>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
>
|
||||||
|
Yes, initiate booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
h={listStyle ? 34 : undefined}
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
leftSection={<Icon size={15} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
styles={
|
||||||
|
listStyle
|
||||||
|
? {
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
fw={listStyle ? undefined : 700}
|
||||||
|
fz={listStyle ? undefined : 13}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +252,12 @@ export function ContractCustomerActionCell({
|
|||||||
return (
|
return (
|
||||||
<Group gap={8} wrap="nowrap" justify="flex-end">
|
<Group gap={8} wrap="nowrap" justify="flex-end">
|
||||||
{docButton}
|
{docButton}
|
||||||
<ContractCustomerAction contract={contract} bookings={bookings} size="sm" listStyle />
|
<ContractCustomerAction
|
||||||
|
contract={contract}
|
||||||
|
bookings={bookings}
|
||||||
|
size="sm"
|
||||||
|
listStyle
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||||
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
PackagePlus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-facing clearance section on the booking detail page: a compact
|
* Customer-facing clearance section on the booking detail page: a compact
|
||||||
* status summary with a single action button. The document grid, re-uploads,
|
* status summary with a single action button. The document grid and re-uploads
|
||||||
* and the shipment-day picker all live in the shared {@link BookingActionModal}
|
* live in the shared {@link BookingActionModal} (the same modal the My
|
||||||
* (the same modal the My Shipments list uses), so the flow behaves identically
|
* Shipments list uses); a finished bare instance instead shows a "Book" button
|
||||||
* from both entry points.
|
* that navigates to the booking form.
|
||||||
*/
|
*/
|
||||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
|
// BOOK: clearance finished on a bare instance — go straight to the booking
|
||||||
|
// form (cargo + shipment day + window check) instead of opening the modal.
|
||||||
|
const isBookAction = action?.kind === "BOOK" && Boolean(action.to);
|
||||||
|
|
||||||
if (status === "OPERATION_REQUESTED") {
|
if (status === "OPERATION_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const summary =
|
const summary =
|
||||||
status === "CLEARANCE_READY" ? (
|
status === "CLEARANCE_READY" ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||||
Clearance is complete. Pick a shipment day and proceed to operation.
|
{isBookAction
|
||||||
|
? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
|
||||||
|
: "Clearance is complete. Pick a shipment day and proceed to operation."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||||
@@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ClipboardList size={16} />}
|
leftSection={
|
||||||
onClick={() => setModalOpen(true)}
|
isBookAction ? (
|
||||||
|
<PackagePlus size={16} />
|
||||||
|
) : (
|
||||||
|
<ClipboardList size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
isBookAction ? navigate(action!.to!) : setModalOpen(true)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
{summary}
|
{summary}
|
||||||
|
|
||||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||||
Use “{action?.label ?? "the action button"}” to manage your clearance
|
{isBookAction
|
||||||
documents.
|
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||||
|
: `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<BookingActionModal
|
{!isBookAction && (
|
||||||
booking={booking}
|
<BookingActionModal
|
||||||
opened={modalOpen}
|
booking={booking}
|
||||||
onClose={() => setModalOpen(false)}
|
opened={modalOpen}
|
||||||
/>
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export function StatusHero({
|
|||||||
}) {
|
}) {
|
||||||
const status = booking.status;
|
const status = booking.status;
|
||||||
const stage = resolveStage(booking);
|
const stage = resolveStage(booking);
|
||||||
|
// Contract-drawdown instance in the clearance gate: it was INITIATED with one
|
||||||
|
// click (no cargo/date yet), not submitted through the wizard.
|
||||||
|
const isInitiatedInstance =
|
||||||
|
status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId);
|
||||||
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
||||||
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
||||||
// headline is overridden here. Bookings with a per-booking journey carry the
|
// headline is overridden here. Bookings with a per-booking journey carry the
|
||||||
@@ -78,7 +82,14 @@ export function StatusHero({
|
|||||||
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
||||||
stage,
|
stage,
|
||||||
}
|
}
|
||||||
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
: isInitiatedInstance
|
||||||
|
? {
|
||||||
|
title: "Booking initiated — clearance documents needed",
|
||||||
|
description:
|
||||||
|
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
||||||
const negative = isNegative(status);
|
const negative = isNegative(status);
|
||||||
const draft = isDraftLike(status);
|
const draft = isDraftLike(status);
|
||||||
|
|
||||||
@@ -123,6 +134,11 @@ export function StatusHero({
|
|||||||
current={stage}
|
current={stage}
|
||||||
tone={draft ? "ink" : "green"}
|
tone={draft ? "ink" : "green"}
|
||||||
negative={negative}
|
negative={negative}
|
||||||
|
// Contract drawdowns are initiated with one click, not submitted
|
||||||
|
// through the wizard — relabel the stage for them.
|
||||||
|
labelOverrides={
|
||||||
|
booking.contractId ? { 1: "Initiated" } : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -132,10 +148,13 @@ export function StatusHero({
|
|||||||
function ProgressTracker({
|
function ProgressTracker({
|
||||||
current,
|
current,
|
||||||
tone = "green",
|
tone = "green",
|
||||||
|
labelOverrides,
|
||||||
}: {
|
}: {
|
||||||
current: number;
|
current: number;
|
||||||
tone?: "green" | "ink";
|
tone?: "green" | "ink";
|
||||||
negative?: boolean;
|
negative?: boolean;
|
||||||
|
/** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */
|
||||||
|
labelOverrides?: Record<number, string>;
|
||||||
}) {
|
}) {
|
||||||
const last = PROGRESS_STAGES.length - 1;
|
const last = PROGRESS_STAGES.length - 1;
|
||||||
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
|
||||||
@@ -227,7 +246,7 @@ function ProgressTracker({
|
|||||||
ta="center"
|
ta="center"
|
||||||
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
c={state === "idle" ? "#9AA8B5" : "#10202F"}
|
||||||
>
|
>
|
||||||
{stage.label}
|
{labelOverrides?.[idx] ?? stage.label}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Box, Button } from "@mantine/core";
|
import { Box, Button } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
PackagePlus,
|
||||||
|
PencilLine,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -19,6 +26,7 @@ const ICON_BY_KIND: Record<
|
|||||||
UPLOAD_DOCUMENTS: Upload,
|
UPLOAD_DOCUMENTS: Upload,
|
||||||
FIX_DOCUMENTS: AlertCircle,
|
FIX_DOCUMENTS: AlertCircle,
|
||||||
SCHEDULE_OPERATION: ArrowRight,
|
SCHEDULE_OPERATION: ArrowRight,
|
||||||
|
BOOK: PackagePlus,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BookingActionButtonProps {
|
interface BookingActionButtonProps {
|
||||||
@@ -39,6 +47,7 @@ export function BookingActionButton({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
}: BookingActionButtonProps) {
|
}: BookingActionButtonProps) {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Staff returned the booking for changes — let the customer update the docs
|
// Staff returned the booking for changes — let the customer update the docs
|
||||||
// they submitted and resubmit, in place.
|
// they submitted and resubmit, in place.
|
||||||
@@ -49,6 +58,9 @@ export function BookingActionButton({
|
|||||||
|
|
||||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||||
const label = action ? action.label : "Update & resubmit";
|
const label = action ? action.label : "Update & resubmit";
|
||||||
|
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||||
|
// same page a one-time booking uses — instead of opening the modal.
|
||||||
|
const navigateTo = action?.kind === "BOOK" ? action.to : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Mantine modals portal to <body>, but React events still bubble through
|
// Mantine modals portal to <body>, but React events still bubble through
|
||||||
@@ -65,7 +77,8 @@ export function BookingActionButton({
|
|||||||
leftSection={<Icon size={14} />}
|
leftSection={<Icon size={14} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
open();
|
if (navigateTo) navigate(navigateTo);
|
||||||
|
else open();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -77,7 +90,7 @@ export function BookingActionButton({
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : navigateTo ? null : (
|
||||||
<BookingActionModal booking={booking} opened={opened} onClose={close} />
|
<BookingActionModal booking={booking} opened={opened} onClose={close} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -48,24 +48,25 @@ function BookingActionModalBody({
|
|||||||
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Sized and styled to match the contract clearance modal
|
||||||
|
// (ContractClearanceAction) so both flows read as the same surface.
|
||||||
<Modal
|
<Modal
|
||||||
opened
|
opened
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
centered
|
centered
|
||||||
size={560}
|
size="xl"
|
||||||
radius={16}
|
radius="md"
|
||||||
padding={24}
|
|
||||||
title={
|
title={
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={16} fw={800} c="#10202F">
|
<Text fw={700} fz={16}>
|
||||||
{action?.title ?? "Booking"}
|
{action?.title ?? "Clearance documents"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} c="dimmed" ff="monospace">
|
<Text fz={12} c="dimmed" ff="monospace">
|
||||||
{reference}
|
{reference}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
styles={{ body: { paddingTop: 8 } }}
|
styles={{ body: { paddingTop: 8 } }}
|
||||||
>
|
>
|
||||||
{flow.isLoading || !flow.clearance ? (
|
{flow.isLoading || !flow.clearance ? (
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { Freight } from "@edr/types";
|
|||||||
export type BookingActionKind =
|
export type BookingActionKind =
|
||||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||||
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
|
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||||
|
| "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form
|
||||||
|
|
||||||
export interface BookingNextAction {
|
export interface BookingNextAction {
|
||||||
kind: BookingActionKind;
|
kind: BookingActionKind;
|
||||||
@@ -17,6 +18,8 @@ export interface BookingNextAction {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Modal title. */
|
/** Modal title. */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */
|
||||||
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||||
@@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActionBooking = Pick<
|
||||||
|
Freight.IBooking,
|
||||||
|
"id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Initiated instance still carrying no cargo/price (clearance-first flow). */
|
||||||
|
function isBareInstance(booking: ActionBooking): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the customer's next clearance/operation action for a booking, or
|
* Resolve the customer's next clearance/operation action for a booking, or
|
||||||
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
|
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
|
||||||
@@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
|||||||
* "under review" state when nothing is actually queried.
|
* "under review" state when nothing is actually queried.
|
||||||
*/
|
*/
|
||||||
export function getBookingNextAction(
|
export function getBookingNextAction(
|
||||||
booking: Pick<Freight.IBooking, "status">,
|
booking: ActionBooking,
|
||||||
): BookingNextAction | null {
|
): BookingNextAction | null {
|
||||||
|
if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) {
|
||||||
|
// Customs (Path B): GL completes the booking — the customer can only view
|
||||||
|
// the finished clearance in the modal.
|
||||||
|
if (booking.customsClearingEnabled) {
|
||||||
|
return {
|
||||||
|
kind: "SCHEDULE_OPERATION",
|
||||||
|
label: "View clearance",
|
||||||
|
title: "Clearance complete",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Non-customs (Path A): straight to the booking form — cargo + shipment
|
||||||
|
// day + window check, the same page a one-time booking uses.
|
||||||
|
return {
|
||||||
|
kind: "BOOK",
|
||||||
|
label: "Book",
|
||||||
|
title: "Book your shipment",
|
||||||
|
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
||||||
|
};
|
||||||
|
}
|
||||||
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +92,7 @@ export function getBookingNextAction(
|
|||||||
* booking that needs documents updated and resubmitting. Used to decide whether
|
* booking that needs documents updated and resubmitting. Used to decide whether
|
||||||
* to render {@link BookingActionButton}.
|
* to render {@link BookingActionButton}.
|
||||||
*/
|
*/
|
||||||
export function bookingHasInlineAction(
|
export function bookingHasInlineAction(booking: ActionBooking): boolean {
|
||||||
booking: Pick<Freight.IBooking, "status">,
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
booking.status === "CHANGES_REQUESTED" ||
|
booking.status === "CHANGES_REQUESTED" ||
|
||||||
getBookingNextAction(booking) !== null
|
getBookingNextAction(booking) !== null
|
||||||
|
|||||||
@@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||||
* the one whose train dispatches soonest, so it lines up with the departure-date
|
* the one that OPENS soonest from now. Two guards matter here:
|
||||||
* ordering of the cards. Returns `null` when nothing upcoming carries an opening
|
* - only openings strictly in the future qualify. A train mid-cycle
|
||||||
* time. (`windowOpensAt` is still required so the banner can name a come-back time.)
|
* (doc-review/payment) still reports the window that already opened and
|
||||||
|
* closed; showing that past time as "next" told customers to come back for
|
||||||
|
* a window that was over.
|
||||||
|
* - ordered by opening time, not departure date — "next window" is the next
|
||||||
|
* moment booking opens, which may belong to a later-departing train.
|
||||||
|
* Returns `null` when nothing upcoming carries a future opening time.
|
||||||
*/
|
*/
|
||||||
export function soonestUpcomingWindow(
|
export function soonestUpcomingWindow(
|
||||||
windows: MyBookingWindow[],
|
windows: MyBookingWindow[],
|
||||||
): MyBookingWindow | null {
|
): MyBookingWindow | null {
|
||||||
|
const now = Date.now();
|
||||||
const upcoming = windows
|
const upcoming = windows
|
||||||
.filter((w) => !w.isOpenNow && w.windowOpensAt)
|
.filter(
|
||||||
.sort((a, b) => {
|
(w) =>
|
||||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
!w.isOpenNow &&
|
||||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
w.windowOpensAt &&
|
||||||
if (da !== db) return da - db;
|
new Date(w.windowOpensAt).getTime() > now,
|
||||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
)
|
||||||
});
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(),
|
||||||
|
);
|
||||||
return upcoming[0] ?? null;
|
return upcoming[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user