mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
enhance gate pass and freight payment handling in train scheduling
- Updated the logic in to ensure that a booking only earns its gate pass once the freight charges are settled. - Added logging for bookings that have not settled freight payment when securing gate passes. - Modified seeders to ensure that bookings have associated company profiles to prevent data inconsistencies. - Updated freight permissions to include new clearance actions for bookings. - Enhanced the UI to reflect changes in the clearance process, including new shipment request pages and improved status handling in the clearance action panel. - Adjusted the contract clearance list to accommodate both customs contracts and shipment bookings. - Improved the handling of GENERAL contracts in various components to ensure proper booking flow and visibility.
This commit is contained in:
@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
if (!statuses.length) return [];
|
if (!statuses.length) return [];
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: { status: In(statuses) },
|
where: { status: In(statuses) },
|
||||||
|
relations: {
|
||||||
|
company: true,
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
},
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, ClearanceMilestone>();
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
|
|||||||
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
|
* 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 on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
|
||||||
* milestone metadata so the timeline shows it.
|
* 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(
|
async assignRisk(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
): Promise<ClearanceMilestone> {
|
): Promise<ClearanceMilestone> {
|
||||||
|
await this.assertT1Closed(bookingId);
|
||||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
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<void> {
|
||||||
|
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
|
* Advise duty & tax (amount + declaration serial) and complete the
|
||||||
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
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:
|
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||||
// there is no window and no date — staff accept them onto a train at
|
// 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.
|
// 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
|
// 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
|
// created while the route's booking window is open — import: the day's window
|
||||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||||||
// enter clearance first and are scheduled later, so they are not gated here.
|
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||||||
if (!generalCustoms && !isIntercity) {
|
// are scheduled later, so they are not gated here.
|
||||||
|
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
@@ -174,7 +184,10 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
status:
|
||||||
|
generalCustoms || generalSelfClear
|
||||||
|
? 'AWAITING_DOCUMENTS'
|
||||||
|
: 'OPERATION_REQUEST_PENDING',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -259,9 +272,10 @@ export class ContractBookingService {
|
|||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||||
booking.id,
|
booking.id,
|
||||||
);
|
);
|
||||||
const intendedStatus = generalCustoms
|
const intendedStatus =
|
||||||
? 'AWAITING_DOCUMENTS'
|
generalCustoms || generalSelfClear
|
||||||
: 'OPERATION_REQUEST_PENDING';
|
? 'AWAITING_DOCUMENTS'
|
||||||
|
: 'OPERATION_REQUEST_PENDING';
|
||||||
if (
|
if (
|
||||||
withContainers &&
|
withContainers &&
|
||||||
freightType === 'CONTAINER' &&
|
freightType === 'CONTAINER' &&
|
||||||
|
|||||||
@@ -632,16 +632,15 @@ export class ContractTransitionService {
|
|||||||
contract.customsClearingEnabled ?? false,
|
contract.customsClearingEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
||||||
// level: there is no contract clearance cycle. The contract just becomes
|
// both paths. Customs (Path B): the customer files shipment requests, GL
|
||||||
// active; the customer then files shipment requests and GL books + clears
|
// books each one and the booking carries its own clearance. Self-clearance
|
||||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
// (Path A): the customer books, then uploads the clearance docs on that
|
||||||
// cycle below.
|
// booking for Operations to review. Only ONE_TIME contracts keep the
|
||||||
const isGeneralCustoms =
|
// contract-level cycle below.
|
||||||
contract.contractKind === 'GENERAL' &&
|
const isGeneral = contract.contractKind === 'GENERAL';
|
||||||
Boolean(contract.customsClearingEnabled);
|
|
||||||
|
|
||||||
if (clearanceCode && !isGeneralCustoms) {
|
if (clearanceCode && !isGeneral) {
|
||||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
// 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
|
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||||
// distinction is enforced at the review/finalize endpoints, not here.
|
// distinction is enforced at the review/finalize endpoints, not here.
|
||||||
@@ -652,8 +651,8 @@ export class ContractTransitionService {
|
|||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
// clears per booking). Ready for shipment requests / direct booking.
|
// (which clears per booking). Ready for shipment requests / direct booking.
|
||||||
updates.status =
|
updates.status =
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
|||||||
@@ -630,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ export interface BatchBoardSchedule {
|
|||||||
export class BookingBatchService implements OnModuleInit {
|
export class BookingBatchService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(BookingBatchService.name);
|
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<string, Promise<void>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
@@ -1602,21 +1610,92 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return anySettled;
|
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<void> {
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
const anySettled = await this.settleReserved(scheduleId, false);
|
await this.withScheduleLock(scheduleId, () =>
|
||||||
// A settle that allocated/expired anything frees or fills capacity → re-run the
|
this.settleAndTopUp(scheduleId, false),
|
||||||
// fill so the next waiting-list bookings get a fresh pay window (top-up).
|
);
|
||||||
if (anySettled) {
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<boolean> {
|
||||||
|
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(
|
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
|
return true;
|
||||||
// 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);
|
* 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<T>(
|
||||||
|
scheduleId: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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. */
|
/** Allocate paid reservations, expire the rest, then top up. */
|
||||||
async settleBatch(scheduleId: string): Promise<void> {
|
async settleBatch(scheduleId: string): Promise<void> {
|
||||||
this.removeTimeout(scheduleId);
|
this.removeTimeout(scheduleId);
|
||||||
await this.settleReserved(scheduleId, true);
|
await this.withScheduleLock(scheduleId, () =>
|
||||||
const topUpReserved = await this.topUpFill(scheduleId);
|
this.settleAndTopUp(scheduleId, true),
|
||||||
if (topUpReserved > 0) {
|
);
|
||||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
|
||||||
}
|
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,24 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
`(allocate paid / expire unpaid) then concluding the cycle`,
|
`(allocate paid / expire unpaid) then concluding the cycle`,
|
||||||
);
|
);
|
||||||
await this.bookingBatchService.settleDueReservations(schedule.id);
|
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);
|
await this.concludeCycle(schedule, cfg, now);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
|
||||||
|
metadata?: Record<string, unknown> | 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<Partial<Booking>>, 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<void> {
|
||||||
|
return (
|
||||||
|
service as unknown as {
|
||||||
|
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
|
||||||
|
}
|
||||||
|
).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1655,6 +1655,13 @@ export class TrainSchedulingService {
|
|||||||
* clearance views still reading that milestone (older deployed builds) see
|
* clearance views still reading that milestone (older deployed builds) see
|
||||||
* the gate pass as done. Drop once every clearance-api deployment reads
|
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
* 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(
|
private async completeGatepassMilestoneForSchedule(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -1666,20 +1673,49 @@ export class TrainSchedulingService {
|
|||||||
if (bookings.length === 0) return;
|
if (bookings.length === 0) return;
|
||||||
|
|
||||||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||||
|
const bookingIds = bookings.map((b) => b.id);
|
||||||
const rows = await milestoneRepo.find({
|
const rows = await milestoneRepo.find({
|
||||||
where: {
|
where: {
|
||||||
bookingId: In(bookings.map((b) => b.id)),
|
bookingId: In(bookingIds),
|
||||||
milestoneCode: 'GATEPASS_GRANTED',
|
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) {
|
for (const row of rows) {
|
||||||
|
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
|
||||||
if (row.status === 'COMPLETED') continue;
|
if (row.status === 'COMPLETED') continue;
|
||||||
|
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
|
||||||
|
skipped.push(row.bookingId ?? '(unknown)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
row.status = 'COMPLETED';
|
row.status = 'COMPLETED';
|
||||||
row.triggeredAt = securedAt;
|
row.triggeredAt = securedAt;
|
||||||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||||
await milestoneRepo.save(row);
|
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 = {}) {
|
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
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 { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
@@ -91,12 +92,26 @@ export class Batch5TestDataSeeder {
|
|||||||
return;
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
for (const seed of SEEDS) {
|
for (const seed of SEEDS) {
|
||||||
const booking = await bookingRepo.save(
|
const booking = await bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
reference: seed.ref,
|
reference: seed.ref,
|
||||||
|
companyId: companyProfile.companyId,
|
||||||
|
companyProfileId: companyProfile.id,
|
||||||
originYardId: originYard.id,
|
originYardId: originYard.id,
|
||||||
destinationYardId: destYard.id,
|
destinationYardId: destYard.id,
|
||||||
serviceTypeId: serviceType.id,
|
serviceTypeId: serviceType.id,
|
||||||
|
|||||||
@@ -662,9 +662,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
// 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.view,
|
||||||
FREIGHT_PERMS.contracts.opsClearanceReview,
|
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
FREIGHT_PERMS.bookings.clearanceView,
|
||||||
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
director: [
|
director: [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.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 { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.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: { code: 'RAIL_CONTAINER' } })) ??
|
||||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||||
const cargoType = await cargoTypeRepo.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(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,7 +96,7 @@ export class WarehouseDemoSeeder {
|
|||||||
): Promise<Booking> =>
|
): Promise<Booking> =>
|
||||||
bookingRepo.save(
|
bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
...this.demoBookingDefaults(),
|
...this.demoBookingDefaults(companyProfile),
|
||||||
reference,
|
reference,
|
||||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.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.
|
// 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;
|
created += 1;
|
||||||
|
|
||||||
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
||||||
@@ -199,6 +214,7 @@ export class WarehouseDemoSeeder {
|
|||||||
ethYard: Yard,
|
ethYard: Yard,
|
||||||
serviceType: ServiceType,
|
serviceType: ServiceType,
|
||||||
cargoType: CargoType | null,
|
cargoType: CargoType | null,
|
||||||
|
owner: CompanyProfile,
|
||||||
arrival: Date,
|
arrival: Date,
|
||||||
departure: Date,
|
departure: Date,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -238,7 +254,7 @@ export class WarehouseDemoSeeder {
|
|||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const b = await bookingRepo.save(
|
const b = await bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
...this.demoBookingDefaults(),
|
...this.demoBookingDefaults(owner),
|
||||||
reference: `WH-DEMO-ARR-${i}`,
|
reference: `WH-DEMO-ARR-${i}`,
|
||||||
originYardId: djibYard.id,
|
originYardId: djibYard.id,
|
||||||
destinationYardId: ethYard.id,
|
destinationYardId: ethYard.id,
|
||||||
@@ -258,8 +274,10 @@ export class WarehouseDemoSeeder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private demoBookingDefaults(): Partial<Booking> {
|
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
|
||||||
return {
|
return {
|
||||||
|
companyId: owner.companyId,
|
||||||
|
companyProfileId: owner.id,
|
||||||
scheduledDate: new Date(),
|
scheduledDate: new Date(),
|
||||||
contractType: 'SPOT',
|
contractType: 'SPOT',
|
||||||
equipmentReturn: 'TERMINAL',
|
equipmentReturn: 'TERMINAL',
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
|
|||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||||
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
|
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||||
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||||
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
|
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
@@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Hidden for now — Shipment Requests nav item disabled.
|
{
|
||||||
// {
|
label: "Shipment Requests",
|
||||||
// label: "Shipment Requests",
|
href: "/dashboard/shipment-requests",
|
||||||
// href: "/dashboard/shipment-requests",
|
icon: <Send />,
|
||||||
// icon: <Send />,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
},
|
||||||
// },
|
{
|
||||||
|
label: "Self-Clearance Review",
|
||||||
|
href: "/dashboard/contracts/ops-clearance",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
@@ -752,7 +757,6 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Hidden for now — Shipment Requests pages disabled.
|
|
||||||
<Route
|
<Route
|
||||||
path="shipment-requests"
|
path="shipment-requests"
|
||||||
element={
|
element={
|
||||||
@@ -773,7 +777,6 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
*/}
|
|
||||||
{/* GL (Path B) contract clearance review hub */}
|
{/* GL (Path B) contract clearance review hub */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance"
|
path="contracts/clearance"
|
||||||
@@ -829,10 +832,17 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* 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). */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||||
|
>
|
||||||
|
<DocumentClearanceListPage opsMode />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/:id/create-booking"
|
path="contracts/:id/create-booking"
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function computeImportActiveStep(
|
|||||||
bookingCreated: boolean,
|
bookingCreated: boolean,
|
||||||
bookingMilestones: MilestoneRow[],
|
bookingMilestones: MilestoneRow[],
|
||||||
t1Uploaded: boolean,
|
t1Uploaded: boolean,
|
||||||
|
freightPaid: boolean,
|
||||||
): number {
|
): number {
|
||||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||||
@@ -119,9 +120,12 @@ function computeImportActiveStep(
|
|||||||
if (!clearance.preClearanceFinalized) return 5;
|
if (!clearance.preClearanceFinalized) return 5;
|
||||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||||
if (!bookingCreated) return 7;
|
if (!bookingCreated) return 7;
|
||||||
if (!clearance.gatepassGranted) return 8;
|
// The customer pays the train/freight charges on the booking. Until that
|
||||||
if (!t1Uploaded && !clearance.t1?.closed) return 9;
|
// settles the gate pass is not granted for this booking, so the flow stops here.
|
||||||
if (!clearance.t1?.closed) return 10;
|
if (!freightPaid) return 8;
|
||||||
|
if (!clearance.gatepassGranted) return 9;
|
||||||
|
if (!t1Uploaded && !clearance.t1?.closed) return 10;
|
||||||
|
if (!clearance.t1?.closed) return 11;
|
||||||
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
||||||
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||||
@@ -129,15 +133,15 @@ function computeImportActiveStep(
|
|||||||
const riskAssigned =
|
const riskAssigned =
|
||||||
Boolean(clearance.riskLevel) ||
|
Boolean(clearance.riskLevel) ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||||
if (!riskAssigned) return 11;
|
if (!riskAssigned) return 12;
|
||||||
// Additional duty round is optional — resolved once skipped or paid.
|
// Additional duty round is optional — resolved once skipped or paid.
|
||||||
const secondDutyResolved =
|
const secondDutyResolved =
|
||||||
clearance.secondDuty?.skipped ||
|
clearance.secondDuty?.skipped ||
|
||||||
clearance.secondDuty?.paid ||
|
clearance.secondDuty?.paid ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||||
if (!secondDutyResolved) return 12;
|
if (!secondDutyResolved) return 13;
|
||||||
if (!clearance.importReleaseGranted) return 13;
|
if (!clearance.importReleaseGranted) return 14;
|
||||||
return 14;
|
return 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
function t1FilesFromWorkflow(
|
function t1FilesFromWorkflow(
|
||||||
@@ -243,6 +247,13 @@ export function PhasedClearanceActionPanel({
|
|||||||
const riskAssigned =
|
const riskAssigned =
|
||||||
Boolean(clearance.riskLevel) ||
|
Boolean(clearance.riskLevel) ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||||
|
// Freight (train + service) charges settled on the booking. The gate pass is
|
||||||
|
// only granted to a booking that has paid, so a granted gate pass is server
|
||||||
|
// proof of payment — it keeps the stepper moving on a page whose
|
||||||
|
// bookingMilestones have not loaded yet or point at a different booking.
|
||||||
|
const freightPaid =
|
||||||
|
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
|
||||||
|
Boolean(clearance.gatepassGranted);
|
||||||
const activeStep = useMemo(
|
const activeStep = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isImport
|
isImport
|
||||||
@@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({
|
|||||||
effectiveBookingCreated,
|
effectiveBookingCreated,
|
||||||
bookingMilestones,
|
bookingMilestones,
|
||||||
t1Uploaded,
|
t1Uploaded,
|
||||||
|
freightPaid,
|
||||||
)
|
)
|
||||||
: 0,
|
: 0,
|
||||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
|
[
|
||||||
|
clearance,
|
||||||
|
isImport,
|
||||||
|
effectiveBookingCreated,
|
||||||
|
bookingMilestones,
|
||||||
|
t1Uploaded,
|
||||||
|
freightPaid,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isImport) {
|
if (isImport) {
|
||||||
@@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({
|
|||||||
)}
|
)}
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
|
<Stepper.Step
|
||||||
|
label="Freight payment"
|
||||||
|
description="Customer pays the train and service charges"
|
||||||
|
icon={freightPaid ? <CheckCircle2 size={14} /> : <Receipt size={14} />}
|
||||||
|
>
|
||||||
|
<StepStatus
|
||||||
|
done={freightPaid}
|
||||||
|
pendingLabel="Waiting for the customer to pay the train and service charges. The gate pass is not granted until this settles."
|
||||||
|
doneLabel="Train and service charges settled."
|
||||||
|
/>
|
||||||
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="Secured on the train schedule after wagon allocation"
|
description="Secured on the train schedule after payment and wagon allocation"
|
||||||
icon={
|
icon={
|
||||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ImportGatepassStep clearance={clearance} />
|
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -927,8 +958,16 @@ function ImportT1CloseStep({
|
|||||||
/**
|
/**
|
||||||
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||||
|
* The train may be secured while this booking still owes freight charges; the
|
||||||
|
* booking only picks the gate pass up once its payment settles.
|
||||||
*/
|
*/
|
||||||
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
function ImportGatepassStep({
|
||||||
|
clearance,
|
||||||
|
freightPaid,
|
||||||
|
}: {
|
||||||
|
clearance: ClearanceViewLike;
|
||||||
|
freightPaid: boolean;
|
||||||
|
}) {
|
||||||
const scheduleId = clearance.train?.scheduleId ?? null;
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
|
|
||||||
if (clearance.gatepassGranted) {
|
if (clearance.gatepassGranted) {
|
||||||
@@ -943,6 +982,16 @@ function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!freightPaid) {
|
||||||
|
return (
|
||||||
|
<StepStatus
|
||||||
|
done={false}
|
||||||
|
pendingLabel="Blocked — the customer must pay the train and service charges before the gate pass is granted for this shipment."
|
||||||
|
doneLabel=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
|
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
|
||||||
|
|
||||||
return (
|
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 (
|
||||||
|
<StepStatus
|
||||||
|
done={false}
|
||||||
|
pendingLabel="Available once the T1 is closed."
|
||||||
|
doneLabel=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!canAct || !bookingId) {
|
if (!canAct || !bookingId) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
|
|||||||
@@ -128,7 +128,16 @@ function DirectionIcon({ direction }: { direction: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentClearanceListPage() {
|
export default function DocumentClearanceListPage({
|
||||||
|
opsMode = false,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* true → Operations self-clearance queue: NON-customs bookings whose
|
||||||
|
* per-booking clearance docs the operations team reviews (GENERAL Path A).
|
||||||
|
* false → legacy GL queue: customs bookings only.
|
||||||
|
*/
|
||||||
|
opsMode?: boolean;
|
||||||
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||||
@@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
const isHistory = pageTab === "history";
|
const isHistory = pageTab === "history";
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
queryKey: ["clearance", "list", isHistory],
|
queryKey: ["clearance", "list", isHistory, opsMode],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
bookingsService.list({
|
bookingsService.list({
|
||||||
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
||||||
@@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allRows = useMemo(() => {
|
const allRows = useMemo(() => {
|
||||||
// GL clearance queue: customs bookings only
|
// opsMode: self-clearance (non-customs) bookings; else customs bookings only.
|
||||||
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
|
const rows = (data?.items ?? [])
|
||||||
|
.map(toClearanceRow)
|
||||||
|
.filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms));
|
||||||
|
|
||||||
if (isHistory) {
|
if (isHistory) {
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
@@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [data?.items, isHistory]);
|
}, [data?.items, isHistory, opsMode]);
|
||||||
|
|
||||||
const tabCounts = useMemo(
|
const tabCounts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Document Clearance"
|
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
|
||||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
subtitle={
|
||||||
|
opsMode
|
||||||
|
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
|
||||||
|
: "Review customer documents, raise queries, and finalize clearance for each booking."
|
||||||
|
}
|
||||||
meta={statusBadge}
|
meta={statusBadge}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
Send,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
@@ -49,11 +50,13 @@ import {
|
|||||||
useContractClearanceQueue,
|
useContractClearanceQueue,
|
||||||
useEtClearanceQueue,
|
useEtClearanceQueue,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type QueueTab = "all" | "et";
|
type QueueTab = "all" | "et" | "shipments";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
|
const canCreateBooking = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.contracts.createBooking,
|
||||||
|
);
|
||||||
|
|
||||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||||
@@ -202,16 +209,29 @@ export default function ContractClearanceListPage() {
|
|||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
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 } =
|
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||||
useEtClearanceQueue(queueTab === "et");
|
useEtClearanceQueue(queueTab === "et");
|
||||||
|
const {
|
||||||
|
data: bookingQueue,
|
||||||
|
isLoading: bookingsLoading,
|
||||||
|
isError: bookingsError,
|
||||||
|
isFetching: bookingsFetching,
|
||||||
|
refetch: refetchBookings,
|
||||||
|
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||||
|
|
||||||
const data = queueTab === "et" ? etData : allData;
|
const data = queueTab === "et" ? etData : allData;
|
||||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||||
const isError = queueTab === "et" ? etError : allError;
|
const isError = queueTab === "et" ? etError : allError;
|
||||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
const isFetching =
|
||||||
|
queueTab === "et"
|
||||||
|
? etFetching
|
||||||
|
: queueTab === "shipments"
|
||||||
|
? bookingsFetching
|
||||||
|
: allFetching;
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
if (queueTab === "et") void refetchEt();
|
if (queueTab === "et") void refetchEt();
|
||||||
|
else if (queueTab === "shipments") void refetchBookings();
|
||||||
else void refetchAll();
|
else void refetchAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,9 +259,43 @@ export default function ContractClearanceListPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (canReview || canEt) {
|
||||||
|
opts.push({
|
||||||
|
value: "shipments",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<PackageCheck size={15} />
|
||||||
|
<Box visibleFrom="sm">Shipments</Box>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
return opts;
|
return opts;
|
||||||
}, [canReview, canEt]);
|
}, [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(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
@@ -269,7 +323,7 @@ export default function ContractClearanceListPage() {
|
|||||||
);
|
);
|
||||||
}, [allRows, query]);
|
}, [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 pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
const pagedRows = useMemo(() => {
|
const pagedRows = useMemo(() => {
|
||||||
@@ -410,16 +464,29 @@ export default function ContractClearanceListPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<Group gap="sm" wrap="nowrap">
|
||||||
variant="default"
|
{canCreateBooking ? (
|
||||||
size="lg"
|
<Button
|
||||||
radius="md"
|
variant="filled"
|
||||||
onClick={() => refetch()}
|
color="edr-green"
|
||||||
loading={isFetching}
|
radius="md"
|
||||||
aria-label="Refresh"
|
leftSection={<Send size={15} />}
|
||||||
>
|
onClick={() => navigate("/dashboard/shipment-requests")}
|
||||||
<RefreshCw size={16} />
|
>
|
||||||
</ActionIcon>
|
Shipment requests
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
loading={isFetching}
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{view === "table" ? (
|
{queueTab === "shipments" ? (
|
||||||
|
<ShipmentBookingsTable
|
||||||
|
rows={bookingRows}
|
||||||
|
loading={bookingsLoading}
|
||||||
|
error={bookingsError}
|
||||||
|
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
||||||
|
/>
|
||||||
|
) : view === "table" ? (
|
||||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
<DataTable<ClearanceRow, unknown>
|
<DataTable<ClearanceRow, unknown>
|
||||||
columns={columns}
|
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<ColumnDef<ShipmentBookingRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "booking",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
|
<div className={bookingTable.rowIcon}>
|
||||||
|
<PackageCheck className="size-4" strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{row.original.reference}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
|
{row.original.customerLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" className="truncate">
|
||||||
|
{row.original.originLabel}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" className="truncate">
|
||||||
|
{row.original.destinationLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kind",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Badge variant="light" color="gray" radius="sm">
|
||||||
|
{prettyStatus(row.original.tradeDirection)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" color="gray" radius="sm">
|
||||||
|
{prettyStatus(row.original.freightType)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={shipmentStatusColor(row.original.status)}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{prettyStatus(row.original.status)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chevron",
|
||||||
|
header: "",
|
||||||
|
cell: () => (
|
||||||
|
<Group justify="flex-end" pr="xs">
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!loading && !error && rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap={8} py={48}>
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
|
<Inbox size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text c="dimmed">No shipment bookings in clearance.</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
|
<DataTable<ShipmentBookingRow, unknown>
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={loading ? "loading" : error ? "error" : "success"}
|
||||||
|
onRowClick={(row) => onOpen(row.id)}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClearanceCardGrid({
|
function ClearanceCardGrid({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,62 +1,99 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
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 { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
|
const { data: bookingQueue, isLoading: bookingsLoading } =
|
||||||
|
useBookingDjClearanceQueue();
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
|
const bookingItems = bookingQueue ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="Customs contracts handed off to Djibouti GL."
|
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
||||||
/>
|
/>
|
||||||
{contractsLoading ? (
|
{contractsLoading || bookingsLoading ? (
|
||||||
<Group justify="center" py={60}>
|
<Group justify="center" py={60}>
|
||||||
<Loader color="edr-green" />
|
<Loader color="edr-green" />
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{contractItems.length === 0 ? (
|
{contractItems.length === 0 && bookingItems.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
No Djibouti customs contracts yet.
|
No Djibouti customs work yet.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
contractItems.map((c) => (
|
<>
|
||||||
<Card
|
{contractItems.map((c) => (
|
||||||
key={c.id}
|
<Card
|
||||||
withBorder
|
key={c.id}
|
||||||
radius="md"
|
withBorder
|
||||||
padding="md"
|
radius="md"
|
||||||
style={{ cursor: "pointer" }}
|
padding="md"
|
||||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
<Group justify="space-between" wrap="nowrap">
|
>
|
||||||
<Group gap="sm">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
<Group gap="sm">
|
||||||
<div>
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
<Text fw={700}>{c.reference}</Text>
|
<div>
|
||||||
<Text size="sm" c="dimmed">
|
<Text fw={700}>{c.reference}</Text>
|
||||||
{c.tradeDirection} · {c.status}
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
{c.tradeDirection} · {c.status}
|
||||||
</div>
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="edr-green">
|
||||||
|
Contract
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
</Card>
|
||||||
<Badge variant="light" color="edr-green">
|
))}
|
||||||
Contract
|
{bookingItems.map((b) => (
|
||||||
</Badge>
|
<Card
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
key={b.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/clearance/${b.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm">
|
||||||
|
<PackageCheck
|
||||||
|
size={18}
|
||||||
|
className="text-[color:var(--freight-brand)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{b.reference}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{b.tradeDirection} · {b.status}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="blue">
|
||||||
|
Shipment
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -241,8 +241,12 @@ export default function ContractDetailPage() {
|
|||||||
});
|
});
|
||||||
// Intercity contracts are never window-gated: the shipment rides a passing
|
// Intercity contracts are never window-gated: the shipment rides a passing
|
||||||
// import/export train that staff assign later, so booking is always open.
|
// 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 =
|
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
|
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||||
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||||
|
|||||||
@@ -139,8 +139,14 @@ export default function NewShipmentPage() {
|
|||||||
// open, show the same closed-state notice as the contract page instead of the
|
// open, show the same closed-state notice as the contract page instead of the
|
||||||
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
||||||
// are never window-gated — the shipment rides a passing train that staff
|
// are never window-gated — the shipment rides a passing train that staff
|
||||||
// pick at finalize time, so booking is always open.
|
// pick at finalize time, so booking is always open. GENERAL contracts are not
|
||||||
if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) {
|
// 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 (
|
return (
|
||||||
<Box style={{ padding: "28px 0 0" }}>
|
<Box style={{ padding: "28px 0 0" }}>
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
Reference in New Issue
Block a user