This commit is contained in:
Marshal
2026-07-07 12:28:47 +00:00
parent 1c18fdbd52
commit f300600bfa
63 changed files with 2587 additions and 428 deletions

View File

@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PAID'],
statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -102,6 +102,7 @@ export function computeNextStep(
description: 'Mark shipment as in transit',
};
case 'IN_TRANSIT':
case 'ARRIVED':
return {
action: 'COMPLETE',
description: 'Mark shipment complete',

View File

@@ -485,7 +485,7 @@ export class BookingTransitionService {
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT"]);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: "COMPLETED",

View File

@@ -1019,6 +1019,44 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose
* origin AND destination both lie on the day's corridor stop set — covers
* full-route bookings and sub-corridor bookings (Dire→Djibouti on an
* Addis→…→Djibouti train). The caller still verifies stop ORDER per train
* via the corridor budget; this query only narrows the pool. Same status
* rules and ordering as {@link findBatchPool}.
*/
findBatchPoolByCorridorDay(
corridorYardIds: string[],
day: string,
): Promise<Booking[]> {
if (corridorYardIds.length === 0) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
corridorYardIds,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -605,10 +605,15 @@ export class BookingsService {
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Selected schedule is no longer accepting bookings');
}
if (
schedule.originStationId !== dto.originYardId ||
schedule.destinationStationId !== dto.destinationYardId
) {
// Corridor-aware: the booking's leg must lie on the schedule's route in
// stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
// train) are valid.
const stops = await this.trainSchedulingService.stopYardsForSchedule(
schedule,
);
const fromIdx = stops.indexOf(dto.originYardId);
const toIdx = stops.indexOf(dto.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (dto.scheduledDate) {
@@ -1278,6 +1283,14 @@ export class BookingsService {
): Promise<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const journey = {
bookingStatus: booking.status ?? null,
bookingOriginYardId: booking.originYardId ?? null,
bookingDestinationYardId: booking.destinationYardId ?? null,
loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null,
arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null,
};
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
@@ -1295,6 +1308,7 @@ export class BookingsService {
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
...journey,
};
if (!booking.trainScheduleId) {
@@ -1331,6 +1345,7 @@ export class BookingsService {
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
...journey,
};
}
@@ -1607,7 +1622,7 @@ export class BookingsService {
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}

View File

@@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
@@ -458,6 +459,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
// read arrivedAt (booking arrival), never the schedule's actualArrivalAt.
/** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
loadedByUserId?: string | null;
/** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true })
arrivedByUserId?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
paymentDeadline?: Date | null;