diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 2fe45bd8a..087786715 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository { if (!statuses.length) return []; return this.repository.find({ where: { status: In(statuses) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + }, order: { createdAt: 'DESC' }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts new file mode 100644 index 000000000..eb77533ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; + +type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED'; + +/** + * Risk assignment is gated on the T1 being closed (catalog order + * T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit. + */ +function makeService(t1Status: Status | 'MISSING') { + const rows = new Map(); + if (t1Status !== 'MISSING') { + rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone); + } + const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone; + rows.set('RISK_ASSIGNED', risk); + + const repo = { + findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) => + Promise.resolve(rows.get(where.milestoneCode) ?? null), + ), + save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)), + }; + const dataSource = { getRepository: () => repo } as unknown as DataSource; + return { service: new ClearanceMilestoneService(dataSource), repo, risk }; +} + +describe('ClearanceMilestoneService.assignRisk', () => { + it('rejects the assignment while the T1 is still open', async () => { + const { service, repo } = makeService('PENDING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => { + const { service, repo } = makeService('MISSING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('assigns the risk level once the T1 is closed', async () => { + const { service, risk } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('RED'); + expect(risk.triggeredByUserId).toBe('user-1'); + }); + + it('assigns the risk level when the T1 step was skipped', async () => { + const { service } = makeService('SKIPPED'); + + const saved = await service.assignRisk('b-1', 'YELLOW'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('YELLOW'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 4a58e50be..81ed305e4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -181,6 +181,10 @@ export class ClearanceMilestoneService { * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED * milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the * milestone metadata so the timeline shows it. + * + * Customs cannot risk-rate cargo still moving under transit: the T1 must be + * closed (accepted by GL Ethiopia after the train arrives) first, which is the + * catalog order T1_CLOSED → RISK_ASSIGNED. */ async assignRisk( bookingId: string, @@ -188,9 +192,22 @@ export class ClearanceMilestoneService { userId?: string, note?: string, ): Promise { + await this.assertT1Closed(bookingId); return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); } + /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ + private async assertT1Closed(bookingId: string): Promise { + const t1 = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'T1_CLOSED' }, + }); + if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') { + throw new BadRequestException( + 'The T1 must be closed before a customs risk level can be assigned.', + ); + } + } + /** * Advise duty & tax (amount + declaration serial) and complete the * DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 55dd5c29f..46fb61db1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -122,6 +122,15 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // GENERAL without customs (Path A) ALSO clears per booking: the customer + // uploads his own clearance proof on each booking and Operations reviews it + // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → + // requestOperation machine). DOMESTIC has no border, so no gate. + const generalSelfClear = + contract.contractKind === 'GENERAL' && + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC'; + // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at // finalize time, so both the window gate and scheduledDate are skipped. @@ -140,9 +149,10 @@ export class ContractBookingService { // Booking-window gate (config-driven): an operations booking may only be // created while the route's booking window is open — import: the day's window // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); - // export: within exportBookingLeadHours of departure. Customs Path B bookings - // enter clearance first and are scheduled later, so they are not gated here. - if (!generalCustoms && !isIntercity) { + // export: within exportBookingLeadHours of departure. Bookings that enter the + // clearance gate first (Path B customs AND Path A per-booking self-clearance) + // are scheduled later, so they are not gated here. + if (!generalCustoms && !generalSelfClear && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, @@ -174,7 +184,10 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', + status: + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -259,9 +272,10 @@ export class ContractBookingService { const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); - const intendedStatus = generalCustoms - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; + const intendedStatus = + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; if ( withContainers && freightType === 'CONTAINER' && diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9f12b937d..e25c10054 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -632,16 +632,15 @@ export class ContractTransitionService { contract.customsClearingEnabled ?? false, ); - // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract - // level: there is no contract clearance cycle. The contract just becomes - // active; the customer then files shipment requests and GL books + clears - // each one. ONE_TIME customs and Path A self-clearance keep the contract - // cycle below. - const isGeneralCustoms = - contract.contractKind === 'GENERAL' && - Boolean(contract.customsClearingEnabled); + // GENERAL contracts run clearance PER BOOKING, not at the contract level — + // both paths. Customs (Path B): the customer files shipment requests, GL + // books each one and the booking carries its own clearance. Self-clearance + // (Path A): the customer books, then uploads the clearance docs on that + // booking for Operations to review. Only ONE_TIME contracts keep the + // contract-level cycle below. + const isGeneral = contract.contractKind === 'GENERAL'; - if (clearanceCode && !isGeneralCustoms) { + if (clearanceCode && !isGeneral) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. @@ -652,8 +651,8 @@ export class ContractTransitionService { updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { - // No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which - // clears per booking). Ready for shipment requests / direct booking. + // No contract-level clearance gate — DOMESTIC, or any GENERAL contract + // (which clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 6f59ca123..b7156880f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -630,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => { expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); }); }); + + describe('settleDueReservations — expire then promote the waiting list', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const trainId = 'train-a'; + // 14m / 70t default wagon → two wagon slots on this locomotive. + const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 }; + + const booking = (id: string, priority: number, overrides = {}): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + originYardId, + destinationYardId, + trainScheduleId: trainId, + ...overrides, + }) as unknown as Booking; + + beforeEach(() => { + const scheduleRow = { + id: trainId, + maxWagons: 2, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainId}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow); + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + scheduledDepartureDate: scheduleRow.scheduledDepartureDate, + originStationId: originYardId, + destinationStationId: destinationYardId, + }); + }); + + it('promotes a waiting booking into the wagons an expired reservation frees', async () => { + // One reservation whose pay window lapsed, and one booking on the waiting list. + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + const waiting = booking('waiting', 10, { trainScheduleId: null }); + + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one + .mockResolvedValue([]); // afterwards nothing is reserved + // The day pool the top-up draws from: only the waiting booking is eligible. + bookingsRepository.findBatchPoolByCorridorDay + .mockResolvedValueOnce([waiting]) + .mockResolvedValue([]); + + await service.settleDueReservations(trainId); + + // The lapsed reservation expired... + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed'); + // ...and the waiting booking was promoted in the SAME settle, not next cycle. + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + }); + + it('serialises concurrent settles so the same reservation is not settled twice', async () => { + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + // Both callers read the reservation; the lock must stop the second from + // acting on rows the first already expired. (The PAYMENT transition and the + // tick's overdue backstop do exactly this, in the same second.) + let reads = 0; + bookingsRepository.findReservedForSchedule.mockImplementation(() => { + reads += 1; + return Promise.resolve(reads === 1 ? [lapsed] : []); + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + + await Promise.all([ + service.settleDueReservations(trainId), + service.settleDueReservations(trainId), + ]); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index f0ac6f4a4..cc9e48018 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -219,6 +219,14 @@ export interface BatchBoardSchedule { export class BookingBatchService implements OnModuleInit { private readonly logger = new Logger(BookingBatchService.name); + /** + * Serialises settle/top-up per schedule. The PAYMENT phase transition and the + * tick's overdue backstop both call settleDueReservations for the same schedule + * in the same second; without this they interleave and the top-up runs against a + * schedule whose phase has already been concluded. + */ + private readonly scheduleLocks = new Map>(); + constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, @@ -1602,21 +1610,92 @@ export class BookingBatchService implements OnModuleInit { return anySettled; } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + /** + * Durable settle: allocate paid / expire overdue reservations, then top up the + * freed capacity from the waiting list. + * + * Serialised per schedule. Two callers race here every time a payment phase + * ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations` + * backstop. Both read the same reserved rows in the same second, so without the + * lock the second caller re-settles rows the first is mid-way through expiring, + * and `concludeCycle` observes capacity that is neither pre- nor post-expiry. + */ async settleDueReservations(scheduleId: string): Promise { - const anySettled = await this.settleReserved(scheduleId, false); - // A settle that allocated/expired anything frees or fills capacity → re-run the - // fill so the next waiting-list bookings get a fresh pay window (top-up). - if (anySettled) { + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, false), + ); + } + + /** + * Settle, then keep promoting the waiting list until the train can take no more. + * Returns whether anything settled. + * + * One top-up pass is not enough: expiring an N-wagon booking can free room for + * several smaller ones, and reserving those can in turn leave room for the next + * size down. Loop until a pass reserves nothing, so the batch ends with the train + * as full as the pool allows — rather than leaving a booking stranded until the + * next window cycle. + * + * Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so + * `concludeCycle` cannot fire before the promoted customers' deadlines. + */ + private async settleAndTopUp( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { + const anySettled = await this.settleReserved( + scheduleId, + expireUnpaidUnknownDeadline, + ); + if (!anySettled) return false; + + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + + // Bounded: every round either reserves at least one unit (shrinking the pool) + // or breaks. The cap is a backstop against a pathological reserve/expire cycle. + let promoted = 0; + for (let round = 0; round < 10; round += 1) { + const reservedThisRound = await this.topUpFill(scheduleId); + if (reservedThisRound <= 0) break; + promoted += reservedThisRound; + await this.extendPaymentPhaseForTopUp(scheduleId); + } + + if (promoted > 0) { this.logger.log( - `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + `[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` + + `— payment phase extended for them`, ); - const topUpReserved = await this.topUpFill(scheduleId); - // A top-up opened a fresh pay window for waiting bookings — push the - // schedule's PAYMENT phase out so the window tick's concludeCycle doesn't - // fire before those customers' new deadlines and expire them prematurely. - if (topUpReserved > 0) { - await this.extendPaymentPhaseForTopUp(scheduleId); + } + return true; + } + + /** + * Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the + * in-flight run rather than interleaving with it. Single-process only — a second + * API replica would need a row lock on the schedule instead. + */ + private async withScheduleLock( + scheduleId: string, + fn: () => Promise, + ): Promise { + const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve(); + // Chain onto the previous holder; swallow its rejection so one failure does + // not poison every later caller's lock. + const run = inFlight.catch(() => undefined).then(fn); + const gate = run.then( + () => undefined, + () => undefined, + ); + this.scheduleLocks.set(scheduleId, gate); + try { + return await run; + } finally { + // Last one out clears the slot so the map does not grow without bound. + if (this.scheduleLocks.get(scheduleId) === gate) { + this.scheduleLocks.delete(scheduleId); } } } @@ -1626,11 +1705,9 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - await this.settleReserved(scheduleId, true); - const topUpReserved = await this.topUpFill(scheduleId); - if (topUpReserved > 0) { - await this.extendPaymentPhaseForTopUp(scheduleId); - } + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, true), + ); void this.triggerWagonAllocation(scheduleId); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f1978b762..03e1d12ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -304,6 +304,24 @@ export class BookingWindowService implements OnModuleInit { `(allocate paid / expire unpaid) then concluding the cycle`, ); await this.bookingBatchService.settleDueReservations(schedule.id); + + // The settle expires unpaid reservations and promotes the waiting list into + // the wagons they free. Those promoted customers get a fresh pay window, and + // `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to + // cover it. Concluding here on the STALE in-memory timestamp would end the + // cycle the top-up just extended and expire them before they could pay — so + // re-read, and stay in PAYMENT if the deadline moved. + const settled = await this.trainSchedulesRepository.findById(schedule.id); + if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) { + schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt; + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT extended to ` + + `${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` + + `promoted into the freed wagons; not concluding this cycle yet`, + ); + return true; + } + await this.concludeCycle(schedule, cfg, now); return true; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts new file mode 100644 index 000000000..912f437f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts @@ -0,0 +1,118 @@ +import { TrainSchedulingService } from './train-scheduling.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; + +type Row = Pick & { + metadata?: Record | null; + triggeredAt?: Date | null; +}; + +/** + * The gate pass is secured once per train schedule, but each booking only earns + * its GATEPASS_GRANTED milestone after settling freight payment. An unpaid + * booking must not ride a paid neighbour's grant — the train proceeds, that + * booking stays pending. + */ +function makeService(bookings: Array>, rows: Row[]) { + const milestoneRepo = { + find: jest.fn().mockResolvedValue(rows), + save: jest.fn((row: Row) => Promise.resolve(row)), + }; + const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking ? bookingRepo : milestoneRepo, + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + logger: { warn: jest.fn(), log: jest.fn() }, + }); + return { service, milestoneRepo }; +} + +/** Reach the private bridge write under test. */ +function grant(service: TrainSchedulingService, at: Date): Promise { + return ( + service as unknown as { + completeGatepassMilestoneForSchedule(id: string, at: Date): Promise; + } + ).completeGatepassMilestoneForSchedule('sched-1', at); +} + +const securedAt = new Date('2026-07-09T08:00:00.000Z'); + +describe('gate pass is withheld from bookings that have not paid freight', () => { + it('grants the paid booking and leaves the unpaid one pending', async () => { + const rows: Row[] = [ + { bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [ + { id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + { id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + ], + rows, + ); + + await grant(service, securedAt); + + const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r); + expect(saved).toHaveLength(1); + expect(saved[0]!.bookingId).toBe('paid'); + expect(saved[0]!.status).toBe('COMPLETED'); + expect(saved[0]!.triggeredAt).toBe(securedAt); + + const unpaid = rows.find( + (r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED', + ); + expect(unpaid!.status).toBe('PENDING'); + }); + + it('treats a booking paid outside the milestone path as paid', async () => { + // Some payment paths settle the invoice without writing the milestone; the + // clearance views self-heal it on read, so the gate pass must not lag. + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).toHaveBeenCalledTimes(1); + expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1'); + }); + + it('leaves an already-granted milestone untouched', async () => { + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); + + it('does nothing when the schedule carries no customs bookings', async () => { + const { service, milestoneRepo } = makeService([], []); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 0c366ed05..de57b778b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1655,6 +1655,13 @@ export class TrainSchedulingService { * clearance views still reading that milestone (older deployed builds) see * the gate pass as done. Drop once every clearance-api deployment reads * ImportDjiboutiOperation.gatepassGrantedAt directly. + * + * A booking only earns its gate pass once the customer has settled the freight + * charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train + * schedule, so an unpaid booking must not ride a paid neighbour's grant: it + * keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the + * train and its paid bookings proceed. Re-securing the gate pass after payment + * settles picks the booking up; so does any later call to this bridge. */ private async completeGatepassMilestoneForSchedule( scheduleId: string, @@ -1666,20 +1673,49 @@ export class TrainSchedulingService { if (bookings.length === 0) return; const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const bookingIds = bookings.map((b) => b.id); const rows = await milestoneRepo.find({ where: { - bookingId: In(bookings.map((b) => b.id)), - milestoneCode: 'GATEPASS_GRANTED', + bookingId: In(bookingIds), + milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']), }, }); + const paidBookingIds = new Set( + rows + .filter( + (r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED', + ) + .map((r) => r.bookingId), + ); + // A booking whose payment settled through a path that never wrote the + // milestone still counts as paid — the clearance views self-heal the row on + // read, and the gate pass must not lag behind that. + for (const booking of bookings) { + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + paidBookingIds.add(booking.id); + } + } + + const skipped: string[] = []; for (const row of rows) { + if (row.milestoneCode !== 'GATEPASS_GRANTED') continue; if (row.status === 'COMPLETED') continue; + if (!row.bookingId || !paidBookingIds.has(row.bookingId)) { + skipped.push(row.bookingId ?? '(unknown)'); + continue; + } row.status = 'COMPLETED'; row.triggeredAt = securedAt; row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; await milestoneRepo.save(row); } + + if (skipped.length > 0) { + this.logger.warn( + `Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`, + ); + } } async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index c5a49e629..ebee2a547 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; @@ -91,12 +92,26 @@ export class Batch5TestDataSeeder { return; } + // bookings.company_id AND bookings.company_profile_id are both NOT NULL, so a + // seed booking needs an owning company profile. Resolve the profile and take + // its company from it, so the two columns can never disagree. Without this the + // seeder aborted on its first insert. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); + if (!companyProfile) { + this.logger.warn('No company profile found; skipping Batch 5 seed'); + return; + } + const now = new Date(); for (const seed of SEEDS) { const booking = await bookingRepo.save( bookingRepo.create({ reference: seed.ref, + companyId: companyProfile.companyId, + companyProfileId: companyProfile.id, originYardId: originYard.id, destinationYardId: destYard.id, serviceTypeId: serviceType.id, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 8d70bc61c..5039e5672 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -662,9 +662,13 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, // Path A (no customs): Operations reviews the customer's self-clearance docs - // on the contract before the customer may create a shipment booking. + // — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL + // contracts (booking-level document review → finalize → CLEARANCE_READY). FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.opsClearanceReview, + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), ], director: [ diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index fbc19100a..d8e585a35 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -60,10 +61,16 @@ export class WarehouseDemoSeeder { (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? (await serviceTypeRepo.findOne({ where: { isActive: true } })); const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + // bookings.company_id AND company_profile_id are both NOT NULL — a demo booking + // still needs an owner. Take the company from the profile so they always agree. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); - if (!djibYard || !ethYard || !serviceType) { + if (!djibYard || !ethYard || !serviceType || !companyProfile) { this.logger.warn( - `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + `Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` + + `svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`, ); return; } @@ -89,7 +96,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(companyProfile), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -182,7 +189,15 @@ export class WarehouseDemoSeeder { } // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. - await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + await this.seedArrivedImportTrain( + djibYard, + ethYard, + serviceType, + cargoType, + companyProfile, + ago(60), + ago(360), + ); created += 1; this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); @@ -199,6 +214,7 @@ export class WarehouseDemoSeeder { ethYard: Yard, serviceType: ServiceType, cargoType: CargoType | null, + owner: CompanyProfile, arrival: Date, departure: Date, ): Promise { @@ -238,7 +254,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(owner), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -258,8 +274,10 @@ export class WarehouseDemoSeeder { } } - private demoBookingDefaults(): Partial { + private demoBookingDefaults(owner: CompanyProfile): Partial { return { + companyId: owner.companyId, + companyProfileId: owner.id, scheduledDate: new Date(), contractType: 'SPOT', equipmentReturn: 'TERMINAL', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index df8db49d4..0bee03fc6 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -// Hidden for now — Shipment Requests pages disabled (imports kept commented). -// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; @@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // Hidden for now — Shipment Requests nav item disabled. - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -752,7 +757,6 @@ const App = () => { } /> - {/* Hidden for now — Shipment Requests pages disabled. { } /> - */} {/* GL (Path B) contract clearance review hub */} { } /> - {/* Path A ops queue out of scope for now → fold into the GL hub. */} + {/* Path A — Operations reviews per-booking self-clearance documents + (GENERAL contracts without customs). */} } + element={ + + + + } /> isImport @@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({ effectiveBookingCreated, bookingMilestones, t1Uploaded, + freightPaid, ) : 0, - [clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded], + [ + clearance, + isImport, + effectiveBookingCreated, + bookingMilestones, + t1Uploaded, + freightPaid, + ], ); if (isImport) { @@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({ )} + : } + > + + + : } > - + + ); + } + const wagonAllocated = Boolean(clearance.train?.wagonAllocated); return ( @@ -1015,6 +1064,18 @@ function RiskStep({ ); } + // Customs cannot rate cargo still under transit — the server rejects the + // assignment until the T1 is closed, so do not offer the control yet. + if (!clearance.t1?.closed) { + return ( + + ); + } + if (!canAct || !bookingId) { return ( ("queue"); const [activeTab, setActiveTab] = useState("all"); @@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() { const isHistory = pageTab === "history"; const { data, isLoading, isError, isFetching, refetch } = useQuery({ - queryKey: ["clearance", "list", isHistory], + queryKey: ["clearance", "list", isHistory, opsMode], queryFn: () => bookingsService.list({ status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS, @@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() { }); const allRows = useMemo(() => { - // GL clearance queue: customs bookings only - const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms); + // opsMode: self-clearance (non-customs) bookings; else customs bookings only. + const rows = (data?.items ?? []) + .map(toClearanceRow) + .filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms)); if (isHistory) { return [...rows].sort((a, b) => { @@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() { }); } return rows; - }, [data?.items, isHistory]); + }, [data?.items, isHistory, opsMode]); const tabCounts = useMemo( () => ({ @@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() { (defaultQueue); @@ -202,16 +209,29 @@ export default function ContractClearanceListPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } = - useContractClearanceQueue(queueTab === "all"); + useContractClearanceQueue(queueTab === "all" || queueTab === "shipments"); const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } = useEtClearanceQueue(queueTab === "et"); + const { + data: bookingQueue, + isLoading: bookingsLoading, + isError: bookingsError, + isFetching: bookingsFetching, + refetch: refetchBookings, + } = useBookingEtClearanceQueue(queueTab === "shipments"); const data = queueTab === "et" ? etData : allData; const isLoading = queueTab === "et" ? etLoading : allLoading; const isError = queueTab === "et" ? etError : allError; - const isFetching = queueTab === "et" ? etFetching : allFetching; + const isFetching = + queueTab === "et" + ? etFetching + : queueTab === "shipments" + ? bookingsFetching + : allFetching; const refetch = () => { if (queueTab === "et") void refetchEt(); + else if (queueTab === "shipments") void refetchBookings(); else void refetchAll(); }; @@ -239,9 +259,43 @@ export default function ContractClearanceListPage() { ), }); } + if (canReview || canEt) { + opts.push({ + value: "shipments", + label: ( + + + Shipments + + ), + }); + } return opts; }, [canReview, canEt]); + // GENERAL-contract shipment bookings in per-booking clearance (ET queue). + const bookingRows = useMemo(() => { + const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({ + id: b.id, + reference: b.reference, + customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", + originLabel: b.originYard?.name ?? "—", + destinationLabel: b.destinationYard?.name ?? "—", + tradeDirection: b.tradeDirection ?? "—", + freightType: b.freightType ?? "—", + status: b.status, + })); + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => + r.reference.toLowerCase().includes(q) || + r.customerLabel.toLowerCase().includes(q) || + r.originLabel.toLowerCase().includes(q) || + r.destinationLabel.toLowerCase().includes(q), + ); + }, [bookingQueue, query]); + const allRows = useMemo( () => (data?.items ?? []).map(toClearanceRow), [data?.items], @@ -269,7 +323,7 @@ export default function ContractClearanceListPage() { ); }, [allRows, query]); - const total = rows.length; + const total = queueTab === "shipments" ? bookingRows.length : rows.length; const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pagedRows = useMemo(() => { @@ -410,16 +464,29 @@ export default function ContractClearanceListPage() { } action={ - refetch()} - loading={isFetching} - aria-label="Refresh" - > - - + + {canCreateBooking ? ( + + ) : null} + refetch()} + loading={isFetching} + aria-label="Refresh" + > + + + } /> @@ -528,7 +595,14 @@ export default function ContractClearanceListPage() { - {view === "table" ? ( + {queueTab === "shipments" ? ( + navigate(`/dashboard/clearance/${id}`)} + /> + ) : view === "table" ? ( columns={columns} @@ -567,6 +641,143 @@ export default function ContractClearanceListPage() { ); } +interface ShipmentBookingRow { + id: string; + reference: string; + customerLabel: string; + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + status: string; +} + +const prettyStatus = (s: string) => + s + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const shipmentStatusColor = (s: string) => { + if (s === "AWAITING_DOCUMENTS") return "yellow"; + if (s === "DOCUMENTS_UNDER_REVIEW") return "blue"; + if (s === "CLEARANCE_READY") return "edr-green"; + return "gray"; +}; + +/** GENERAL-contract shipment bookings currently in per-booking clearance. */ +function ShipmentBookingsTable({ + rows, + loading, + error, + onOpen, +}: { + rows: ShipmentBookingRow[]; + loading: boolean; + error: boolean; + onOpen: (id: string) => void; +}) { + const columns = useMemo[]>( + () => [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( +
+
+ +
+
+

+ {row.original.reference} +

+

+ + {row.original.customerLabel} +

+
+
+ ), + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => ( + + + {row.original.originLabel} + + + + {row.original.destinationLabel} + + + ), + }, + { + id: "kind", + header: () => Type, + cell: ({ row }) => ( + + + {prettyStatus(row.original.tradeDirection)} + + + {prettyStatus(row.original.freightType)} + + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {prettyStatus(row.original.status)} + + ), + }, + { + id: "chevron", + header: "", + cell: () => ( + + + + ), + }, + ], + [], + ); + + if (!loading && !error && rows.length === 0) { + return ( + + + + + No shipment bookings in clearance. + + ); + } + + return ( + + + columns={columns} + data={rows} + status={loading ? "loading" : error ? "error" : "success"} + onRowClick={(row) => onOpen(row.id)} + containerClassName="border-0 shadow-none bg-transparent" + /> + + ); +} + function ClearanceCardGrid({ rows, loading, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 0b7456851..733c882bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,62 +1,99 @@ import { useNavigate } from "react-router-dom"; import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; -import { ChevronRight, Ship } from "lucide-react"; +import { ChevronRight, PackageCheck, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; +import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); + const { data: bookingQueue, isLoading: bookingsLoading } = + useBookingDjClearanceQueue(); const contractItems = contractQueue?.items ?? []; + const bookingItems = bookingQueue ?? []; return ( - {contractsLoading ? ( + {contractsLoading || bookingsLoading ? ( ) : ( - {contractItems.length === 0 ? ( + {contractItems.length === 0 && bookingItems.length === 0 ? ( - No Djibouti customs contracts yet. + No Djibouti customs work yet. ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
+ <> + {contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + +
- - - Contract - - +
+ ))} + {bookingItems.map((b) => ( + navigate(`/dashboard/clearance/${b.id}`)} + > + + + +
+ {b.reference} + + {b.tradeDirection} · {b.status} + +
+
+ + + Shipment + + +
-
-
- )) + + ))} + )}
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 2bb3561cb..784fa6bf5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -241,8 +241,12 @@ export default function ContractDetailPage() { }); // Intercity contracts are never window-gated: the shipment rides a passing // import/export train that staff assign later, so booking is always open. + // GENERAL contracts are also not gated at creation — the booking enters the + // per-booking clearance gate first and picks its shipment day at proceed time. const bookingWindowOpen = - contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows); + contract?.tradeDirection === "DOMESTIC" || + contract?.contractKind === "GENERAL" || + hasOpenWindow(bookingWindows); // Draw-down capacity per cargo line (GENERAL contracts only). The backend // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index c36a74255..9410fbc7b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -139,8 +139,14 @@ export default function NewShipmentPage() { // open, show the same closed-state notice as the contract page instead of the // form. Still allowed the moment any window isOpenNow. Intercity contracts // are never window-gated — the shipment rides a passing train that staff - // pick at finalize time, so booking is always open. - if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) { + // pick at finalize time, so booking is always open. GENERAL contracts are not + // gated at creation either: the booking enters per-booking clearance first + // and picks its shipment day at proceed time. + if ( + contract.tradeDirection !== "DOMESTIC" && + contract.contractKind !== "GENERAL" && + !hasOpenWindow(bookingWindows) + ) { return (