mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
|
||||||
|
name = "AddBookingWindowGlobalRules1861000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||||
|
ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||||
|
ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8,
|
||||||
|
ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||||
|
ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||||
|
ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
DROP COLUMN IF EXISTS import_window_lead_days,
|
||||||
|
DROP COLUMN IF EXISTS export_booking_lead_hours,
|
||||||
|
DROP COLUMN IF EXISTS window_open_hour,
|
||||||
|
DROP COLUMN IF EXISTS window_duration_hours,
|
||||||
|
DROP COLUMN IF EXISTS doc_review_minutes,
|
||||||
|
DROP COLUMN IF EXISTS payment_window_minutes,
|
||||||
|
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* arriveSchedule used to release only the primary locomotive of a train set, leaving
|
||||||
|
* secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out
|
||||||
|
* on a dispatched train — release every ASSIGNED locomotive that is not attached to a
|
||||||
|
* currently-DISPATCHED schedule.
|
||||||
|
*/
|
||||||
|
export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface {
|
||||||
|
name = "ReleaseStuckAssignedLocomotives1861000000001";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.locomotives l
|
||||||
|
SET status = 'AVAILABLE'
|
||||||
|
WHERE l.status = 'ASSIGNED'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN (
|
||||||
|
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
WHERE tsl.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT t.id AS train_set_id, t.locomotive_id
|
||||||
|
FROM freight.train_sets t
|
||||||
|
WHERE t.locomotive_id IS NOT NULL
|
||||||
|
) loco ON loco.train_set_id = tset.id
|
||||||
|
WHERE ts.status = 'DISPATCHED'
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND loco.locomotive_id = l.id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Data fix — not reversible.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
|
||||||
|
name = "AddScheduleWindowPhases1862000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN window_phase varchar(20) NULL,
|
||||||
|
ADD COLUMN window_opens_at timestamptz NULL,
|
||||||
|
ADD COLUMN window_closes_at timestamptz NULL,
|
||||||
|
ADD COLUMN doc_review_ends_at timestamptz NULL,
|
||||||
|
ADD COLUMN doc_review_completed_at timestamptz NULL,
|
||||||
|
ADD COLUMN payment_phase_ends_at timestamptz NULL,
|
||||||
|
ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_train_schedules_window_phase
|
||||||
|
ON freight.train_schedules (window_phase)
|
||||||
|
WHERE window_phase IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
DROP COLUMN IF EXISTS window_phase,
|
||||||
|
DROP COLUMN IF EXISTS window_opens_at,
|
||||||
|
DROP COLUMN IF EXISTS window_closes_at,
|
||||||
|
DROP COLUMN IF EXISTS doc_review_ends_at,
|
||||||
|
DROP COLUMN IF EXISTS doc_review_completed_at,
|
||||||
|
DROP COLUMN IF EXISTS payment_phase_ends_at,
|
||||||
|
DROP COLUMN IF EXISTS booking_cycle_no;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
|
||||||
|
name = "CreateBookingBatchOffers1863000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.booking_batch_offers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||||
|
offered_wagons integer NOT NULL,
|
||||||
|
total_wagons integer NOT NULL,
|
||||||
|
offered_lines jsonb NULL,
|
||||||
|
offered_weight_tons numeric(12, 3) NOT NULL,
|
||||||
|
offered_amount numeric(14, 2) NOT NULL,
|
||||||
|
offered_pricing_breakdown jsonb NULL,
|
||||||
|
invoice_id uuid NULL,
|
||||||
|
payment_deadline timestamptz NOT NULL,
|
||||||
|
status varchar(10) NOT NULL DEFAULT 'OFFERED',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -991,6 +991,15 @@ export class BookingTransitionService {
|
|||||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
||||||
|
// booking's day still has capacity — nothing below runs and the request stays
|
||||||
|
// pending for staff to move/decline.
|
||||||
|
const isExportTrain =
|
||||||
|
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
||||||
|
const exportScheduleId = isExportTrain
|
||||||
|
? await this.bookingBatchService.pickExportSchedule(booking)
|
||||||
|
: null;
|
||||||
|
|
||||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||||
@@ -1014,7 +1023,15 @@ export class BookingTransitionService {
|
|||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (booking.scheduledDate) {
|
if (exportScheduleId) {
|
||||||
|
// FCFS: reserve the slot and send the payment notification immediately;
|
||||||
|
// paid → auto-allocated by the settle/paid pipeline.
|
||||||
|
const fresh = await this.bookingsService.findById(booking.id);
|
||||||
|
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
|
||||||
|
} else if (booking.tradeDirection === "IMPORT") {
|
||||||
|
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||||
|
// after staff document review, never at accept time.
|
||||||
|
} else if (booking.scheduledDate) {
|
||||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
this.bookingBatchService.enqueueRouteDayProcessing(
|
||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
@@ -1029,6 +1046,12 @@ export class BookingTransitionService {
|
|||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
nextStep: BookingNextStep | null;
|
nextStep: BookingNextStep | null;
|
||||||
|
activeBatchOffer?: {
|
||||||
|
offeredWagons: number;
|
||||||
|
totalWagons: number;
|
||||||
|
offeredAmount: number;
|
||||||
|
paymentDeadline: Date;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
@@ -1044,11 +1067,16 @@ export class BookingTransitionService {
|
|||||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||||
: null;
|
: null;
|
||||||
const nextStep = computeNextStep(booking, nextPending);
|
const nextStep = computeNextStep(booking, nextPending);
|
||||||
|
const activeBatchOffer =
|
||||||
|
booking.status === "SELECTED_FOR_BATCH"
|
||||||
|
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
latestChangeRequestNote: note?.note ?? null,
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
contractSummary: summary,
|
contractSummary: summary,
|
||||||
nextStep,
|
nextStep,
|
||||||
|
activeBatchOffer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,12 +617,14 @@ export class BookingsController {
|
|||||||
async uploadBookingDeliveryOrder(
|
async uploadBookingDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
file,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
|
vesselDepartureDate,
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
|
import {
|
||||||
|
ContractDocPhase,
|
||||||
|
type ClearanceFinalInvoiceSummary,
|
||||||
|
type ClearanceSecondDuty,
|
||||||
|
type ClearanceT1State,
|
||||||
|
type ClearanceTrainState,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
@@ -66,6 +72,21 @@ export interface BookingClearanceView {
|
|||||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
|
/** Train link state for the booking (both directions). */
|
||||||
|
train?: ClearanceTrainState | null;
|
||||||
|
gatepassGranted?: boolean;
|
||||||
|
gatepassAt?: string | null;
|
||||||
|
t1Closed?: boolean;
|
||||||
|
t1ClosedAt?: string | null;
|
||||||
|
offloaded?: boolean;
|
||||||
|
/** GL Djibouti post-offload final invoice (export). */
|
||||||
|
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||||
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
|
riskLevel?: string | null;
|
||||||
|
riskAssignedAt?: string | null;
|
||||||
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
|
importReleaseGranted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -175,6 +196,20 @@ export class BookingClearanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let train: ClearanceTrainState | null = null;
|
||||||
|
try {
|
||||||
|
train = await this.glOperationsService.trainState(bookingId);
|
||||||
|
} catch {
|
||||||
|
train = null;
|
||||||
|
}
|
||||||
|
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||||
|
const bookingMilestone = (code: string) =>
|
||||||
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
|
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||||
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
|
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
@@ -206,6 +241,33 @@ export class BookingClearanceService {
|
|||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
|
train,
|
||||||
|
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||||
|
gatepassAt:
|
||||||
|
gatepassMilestone?.status === 'COMPLETED'
|
||||||
|
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||||
|
(gatepassMilestone.triggeredAt
|
||||||
|
? gatepassMilestone.triggeredAt.toISOString()
|
||||||
|
: null))
|
||||||
|
: null,
|
||||||
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
|
t1ClosedAt:
|
||||||
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||||
|
: null,
|
||||||
|
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||||
|
finalInvoice,
|
||||||
|
riskLevel:
|
||||||
|
riskMilestone?.status === 'COMPLETED'
|
||||||
|
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||||
|
: null,
|
||||||
|
riskAssignedAt:
|
||||||
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
|
: null,
|
||||||
|
secondDuty,
|
||||||
|
importReleaseGranted:
|
||||||
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +364,12 @@ export class BookingClearanceService {
|
|||||||
: ContractDocPhase.CustomerDuty,
|
: ContractDocPhase.CustomerDuty,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
// Export: the declaration is the last GL ET pre-operation action — release
|
||||||
|
// immediately so the customer can proceed without a separate confirm click.
|
||||||
|
if (tradeDirection === 'EXPORT') {
|
||||||
|
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +517,7 @@ export class BookingClearanceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
vesselDepartureDate?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
@@ -467,6 +536,12 @@ export class BookingClearanceService {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (vesselDepartureDate?.trim()) {
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
if (booking.preClearanceFinalizedAt) {
|
if (booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||||
await this.workflowService.markReadyForOperation(bookingId);
|
await this.workflowService.markReadyForOperation(bookingId);
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
|||||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
|
SECOND_DUTY_ADVISED: {
|
||||||
|
label: 'Additional Duty and Taxes Advised',
|
||||||
|
ownerRegion: 'ET',
|
||||||
|
triggeredByDoc: false,
|
||||||
|
},
|
||||||
|
SECOND_DUTY_PAID: {
|
||||||
|
label: 'Additional Duty and Tax Paid',
|
||||||
|
ownerRegion: 'CUST',
|
||||||
|
triggeredByDoc: true,
|
||||||
|
},
|
||||||
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
@@ -68,6 +78,7 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
|||||||
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
|
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,39 @@ export class ClearanceMilestoneService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find-or-create a post-booking milestone row from the catalog. Needed for codes
|
||||||
|
* added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED).
|
||||||
|
*/
|
||||||
|
async ensureForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
code: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const { postBooking } = splitMilestones(tradeDirection);
|
||||||
|
const idx = postBooking.findIndex((d) => d.code === code);
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Milestone ${code} is not a ${tradeDirection} post-booking milestone`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const def = postBooking[idx]!;
|
||||||
|
return this.repo.save(
|
||||||
|
this.repo.create({
|
||||||
|
bookingId,
|
||||||
|
milestoneCode: def.code,
|
||||||
|
milestoneLabel: def.label,
|
||||||
|
ownerRegion: def.ownerRegion,
|
||||||
|
triggeredByDoc: def.triggeredByDoc,
|
||||||
|
status: 'PENDING',
|
||||||
|
sortOrder: idx,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark a milestone complete (by code) on a booking. */
|
/** Mark a milestone complete (by code) on a booking. */
|
||||||
async completeForBooking(
|
async completeForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
|
import {
|
||||||
|
ContractDocPhase,
|
||||||
|
type ClearanceFinalInvoiceSummary,
|
||||||
|
type ClearanceSecondDuty,
|
||||||
|
type ClearanceT1State,
|
||||||
|
type ClearanceTrainState,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
@@ -80,6 +86,21 @@ export interface ContractClearanceView {
|
|||||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
|
/** Train link state for the booking (both directions; null until a booking is linked). */
|
||||||
|
train?: ClearanceTrainState | null;
|
||||||
|
gatepassGranted?: boolean;
|
||||||
|
gatepassAt?: string | null;
|
||||||
|
t1Closed?: boolean;
|
||||||
|
t1ClosedAt?: string | null;
|
||||||
|
offloaded?: boolean;
|
||||||
|
/** GL Djibouti post-offload final invoice (export). */
|
||||||
|
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||||
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
|
riskLevel?: string | null;
|
||||||
|
riskAssignedAt?: string | null;
|
||||||
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
|
importReleaseGranted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -212,8 +233,9 @@ export class ContractClearanceService {
|
|||||||
files,
|
files,
|
||||||
contract.tradeDirection ?? 'IMPORT',
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
|
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
|
||||||
if (cycle?.bookingId) {
|
if (cycle?.bookingId) {
|
||||||
const bookingFiles = await this.filesService.findByResource(
|
bookingFiles = await this.filesService.findByResource(
|
||||||
cycle.bookingId,
|
cycle.bookingId,
|
||||||
'bookings',
|
'bookings',
|
||||||
);
|
);
|
||||||
@@ -237,11 +259,32 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
let train: ClearanceTrainState | null = null;
|
||||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
let bookingMilestones: ClearanceMilestone[] = [];
|
||||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||||
|
if (cycle?.bookingId) {
|
||||||
|
try {
|
||||||
|
train = await this.glOperationsService.trainState(cycle.bookingId);
|
||||||
|
} catch {
|
||||||
|
train = null;
|
||||||
|
}
|
||||||
|
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||||
cycle.bookingId,
|
cycle.bookingId,
|
||||||
);
|
);
|
||||||
|
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
|
||||||
|
}
|
||||||
|
const bookingMilestone = (code: string) =>
|
||||||
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
|
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||||
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
|
const secondDuty = this.glOperationsService.secondDutyState(
|
||||||
|
bookingMilestones,
|
||||||
|
bookingFiles,
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||||
|
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||||
if (booking) {
|
if (booking) {
|
||||||
nextAction = this.workflowService.computeNextActionForBooking(
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
@@ -286,6 +329,33 @@ export class ContractClearanceService {
|
|||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
|
train,
|
||||||
|
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||||
|
gatepassAt:
|
||||||
|
gatepassMilestone?.status === 'COMPLETED'
|
||||||
|
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||||
|
(gatepassMilestone.triggeredAt
|
||||||
|
? gatepassMilestone.triggeredAt.toISOString()
|
||||||
|
: null))
|
||||||
|
: null,
|
||||||
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
|
t1ClosedAt:
|
||||||
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||||
|
: null,
|
||||||
|
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||||
|
finalInvoice,
|
||||||
|
riskLevel:
|
||||||
|
riskMilestone?.status === 'COMPLETED'
|
||||||
|
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||||
|
: null,
|
||||||
|
riskAssignedAt:
|
||||||
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||||
|
? riskMilestone.triggeredAt.toISOString()
|
||||||
|
: null,
|
||||||
|
secondDuty,
|
||||||
|
importReleaseGranted:
|
||||||
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,6 +943,12 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export: the declaration is the last GL ET pre-booking action — release
|
||||||
|
// immediately so booking creation unlocks without a separate confirm click.
|
||||||
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
|
await this.workflowService.onExportReleased(contractId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1115,7 @@ export class ContractClearanceService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
vesselDepartureDate?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
this.assertPhasedCustoms(contract);
|
this.assertPhasedCustoms(contract);
|
||||||
@@ -1059,6 +1136,11 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (cycle && vesselDepartureDate?.trim()) {
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (cycle?.preClearanceFinalizedAt) {
|
if (cycle?.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||||
await this.workflowService.markReadyForBooking(contractId);
|
await this.workflowService.markReadyForBooking(contractId);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
} from './dto/gl-operations.dto';
|
} from './dto/gl-operations.dto';
|
||||||
import {
|
import {
|
||||||
AdviseContractDutyDto,
|
AdviseContractDutyDto,
|
||||||
|
GatepassDto,
|
||||||
RoAmendmentDto,
|
RoAmendmentDto,
|
||||||
} from './dto/phased-clearance.dto';
|
} from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
@@ -610,9 +611,15 @@ export class ContractsController {
|
|||||||
uploadDeliveryOrder(
|
uploadDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
return this.clearanceService.uploadDeliveryOrder(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
vesselDepartureDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/release-order')
|
@Post(':id/clearance/release-order')
|
||||||
@@ -681,6 +688,30 @@ export class ContractsController {
|
|||||||
return this.clearanceService.djQueue(filter);
|
return this.clearanceService.djQueue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('clearance/dj-schedules')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
||||||
|
djClearanceSchedules() {
|
||||||
|
return this.glOperationsService.djSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clearance/schedules/:scheduleId/gatepass')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
||||||
|
})
|
||||||
|
grantScheduleGatepass(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Body() dto: GatepassDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.grantScheduleGatepass(
|
||||||
|
scheduleId,
|
||||||
|
dto?.gatepassAt,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
@Get('clearance/ops-queue')
|
@Get('clearance/ops-queue')
|
||||||
@@ -889,9 +920,13 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/t1-close')
|
@Post('bookings/:bookingId/t1-close')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives',
|
summary:
|
||||||
|
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
|
||||||
})
|
})
|
||||||
closeT1(
|
closeT1(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@@ -900,6 +935,116 @@ export class ContractsController {
|
|||||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/gatepass')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
||||||
|
grantGatepass(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: GatepassDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.grantGatepass(
|
||||||
|
bookingId,
|
||||||
|
dto?.gatepassAt,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/final-invoice')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)',
|
||||||
|
})
|
||||||
|
createFinalInvoice(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body('amount') amountRaw: string,
|
||||||
|
@Body('currency') currency: string | undefined,
|
||||||
|
@Body('description') description: string | undefined,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.createFinalInvoice(
|
||||||
|
bookingId,
|
||||||
|
{
|
||||||
|
amount: Number(amountRaw),
|
||||||
|
currency: currency?.trim() || 'ETB',
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/final-invoice-slip')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
|
||||||
|
uploadFinalInvoiceSlip(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/final-invoice/confirm')
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
])
|
||||||
|
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
|
||||||
|
confirmFinalInvoicePaid(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.confirmFinalInvoicePaid(
|
||||||
|
bookingId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/second-duty')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(FileInterceptor('attachment'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)',
|
||||||
|
})
|
||||||
|
adviseSecondDuty(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||||
|
@Body('amount') amountRaw: string | undefined,
|
||||||
|
@Body('currency') currency: string | undefined,
|
||||||
|
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||||
|
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.adviseSecondDuty(
|
||||||
|
bookingId,
|
||||||
|
{
|
||||||
|
dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1',
|
||||||
|
amount:
|
||||||
|
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||||
|
currency: currency ?? 'ETB',
|
||||||
|
declarationSerial,
|
||||||
|
},
|
||||||
|
attachment,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/second-duty-slip')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
|
||||||
|
uploadSecondDutySlip(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/documents')
|
@Post('bookings/:bookingId/documents')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
@@ -64,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
Booking,
|
Booking,
|
||||||
BookingContainerUnit,
|
BookingContainerUnit,
|
||||||
]),
|
]),
|
||||||
|
BillingModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ export class RoAmendmentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class GatepassDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
gatepassAt?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface MilestoneMetadata {
|
|||||||
dutyAmount?: number;
|
dutyAmount?: number;
|
||||||
dutyCurrency?: string;
|
dutyCurrency?: string;
|
||||||
declarationSerial?: string;
|
declarationSerial?: string;
|
||||||
|
/** When the gate pass was physically granted (GL DJ captures the time). */
|
||||||
|
gatepassAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
import { DataSource } from 'typeorm';
|
BadRequestException,
|
||||||
import { isT1TransportFileCode, type Freight } from '@edr/types';
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
|
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||||
|
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||||
import {
|
import {
|
||||||
ClearanceIncident,
|
ClearanceIncident,
|
||||||
IncidentType,
|
IncidentType,
|
||||||
} from './entities/clearance-incident.entity';
|
} from './entities/clearance-incident.entity';
|
||||||
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import {
|
import {
|
||||||
persistExportTransportUploads,
|
persistExportTransportUploads,
|
||||||
@@ -43,6 +53,7 @@ export class GlOperationsService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get bookings() {
|
private get bookings() {
|
||||||
@@ -167,12 +178,8 @@ export class GlOperationsService {
|
|||||||
return { uploaded: files.length, completedMilestones };
|
return { uploaded: files.length, completedMilestones };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Wagon-allocation + train-schedule actuals for a booking (both directions). */
|
||||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
|
||||||
* allocation opens the upload window; train departure locks it; train arrival
|
|
||||||
* lets GL Ethiopia close (accept) the T1 set.
|
|
||||||
*/
|
|
||||||
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
|
|
||||||
const booking = await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
|
||||||
@@ -190,19 +197,35 @@ export class GlOperationsService {
|
|||||||
.findOne({ where: { id: booking.trainScheduleId } });
|
.findOne({ where: { id: booking.trainScheduleId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wagonAllocated,
|
||||||
|
departedAt: schedule?.actualDepartureAt
|
||||||
|
? new Date(schedule.actualDepartureAt).toISOString()
|
||||||
|
: null,
|
||||||
|
arrivedAt: schedule?.actualArrivalAt
|
||||||
|
? new Date(schedule.actualArrivalAt).toISOString()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||||
|
* allocation opens the upload window; train departure locks it; train arrival
|
||||||
|
* lets GL Ethiopia close (accept) the T1 set.
|
||||||
|
*/
|
||||||
|
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
|
||||||
|
const train = await this.trainState(bookingId);
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
|
||||||
const closedMilestone = milestones.find(
|
const closedMilestone = milestones.find(
|
||||||
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
|
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
wagonAllocated,
|
wagonAllocated: train.wagonAllocated,
|
||||||
trainDepartedAt: schedule?.actualDepartureAt
|
trainDepartedAt: train.departedAt,
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
trainArrivedAt: train.arrivedAt,
|
||||||
: null,
|
|
||||||
trainArrivedAt: schedule?.actualArrivalAt
|
|
||||||
? new Date(schedule.actualArrivalAt).toISOString()
|
|
||||||
: null,
|
|
||||||
closed: Boolean(closedMilestone),
|
closed: Boolean(closedMilestone),
|
||||||
closedAt: closedMilestone?.triggeredAt
|
closedAt: closedMilestone?.triggeredAt
|
||||||
? new Date(closedMilestone.triggeredAt).toISOString()
|
? new Date(closedMilestone.triggeredAt).toISOString()
|
||||||
@@ -243,38 +266,541 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
|
* Close (accept) the T1/transport document set.
|
||||||
* Completes the T1_CLOSED milestone; the document set becomes final.
|
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
|
||||||
|
* Export: GL Djibouti closes after the gate pass (transport document required).
|
||||||
*/
|
*/
|
||||||
async closeT1(
|
async closeT1(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Freight.ClearanceT1State> {
|
): Promise<Freight.ClearanceT1State> {
|
||||||
const booking = await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||||
throw new BadRequestException('T1 closure applies to import shipments only.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = await this.t1State(bookingId);
|
const state = await this.t1State(bookingId);
|
||||||
if (state.closed) return state;
|
if (state.closed) return state;
|
||||||
if (!state.trainArrivedAt) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'The train has not arrived yet — T1 can be closed only after arrival.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
if (tradeDirection === 'IMPORT') {
|
||||||
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
|
if (!state.trainArrivedAt) {
|
||||||
if (!hasT1) {
|
throw new BadRequestException(
|
||||||
throw new BadRequestException(
|
'The train has not arrived yet — T1 can be closed only after arrival.',
|
||||||
'No T1 transport documents on file — GL Djibouti must upload them first.',
|
);
|
||||||
);
|
}
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
|
||||||
|
if (!hasT1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No T1 transport documents on file — GL Djibouti must upload them first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const done = (code: string) =>
|
||||||
|
milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED';
|
||||||
|
if (!done('EXPORT_TRANSPORT_ISSUED')) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The transport document must be uploaded before T1 can be closed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!done('GATEPASS_GRANTED')) {
|
||||||
|
throw new BadRequestException('Grant the gate pass before closing T1.');
|
||||||
|
}
|
||||||
|
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||||
|
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
||||||
return this.t1State(bookingId);
|
return this.t1State(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Milestones GL DJ implicitly confirms when granting an export gate pass. */
|
||||||
|
private static readonly EXPORT_ARRIVAL_CHAIN = [
|
||||||
|
'CARGO_ARRIVED',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
'LOADED',
|
||||||
|
'DEPARTED_TO_DJIBOUTI',
|
||||||
|
'ARRIVED_AT_DJIBOUTI',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
||||||
|
* Export: requires the train to have arrived at Djibouti; back-fills the
|
||||||
|
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
||||||
|
*/
|
||||||
|
async grantGatepass(
|
||||||
|
bookingId: string,
|
||||||
|
gatepassAt?: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ bookingId: string; gatepassAt: string }> {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if (!booking.customsClearingEnabled) {
|
||||||
|
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
||||||
|
}
|
||||||
|
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
|
||||||
|
const existing = byCode.get('GATEPASS_GRANTED');
|
||||||
|
if (existing?.status === 'COMPLETED') {
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
gatepassAt:
|
||||||
|
existing.metadata?.gatepassAt ??
|
||||||
|
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const train = await this.trainState(bookingId);
|
||||||
|
if (tradeDirection === 'EXPORT') {
|
||||||
|
if (!train.arrivedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||||
|
if (byCode.get(code)?.status === 'PENDING') {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!train.wagonAllocated) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Wagons must be allocated before the gate pass can be granted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = gatepassAt?.trim() || new Date().toISOString();
|
||||||
|
await this.milestoneService.completeWithMetadataForBooking(
|
||||||
|
bookingId,
|
||||||
|
'GATEPASS_GRANTED',
|
||||||
|
{ gatepassAt: at },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { bookingId, gatepassAt: at };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
||||||
|
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
||||||
|
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||||
|
relations: {
|
||||||
|
scheduleBookings: { booking: true },
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
},
|
||||||
|
order: { scheduledDepartureDate: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const withCustoms = schedules
|
||||||
|
.filter((s) => s.status !== 'CANCELLED')
|
||||||
|
.map((s) => ({
|
||||||
|
schedule: s,
|
||||||
|
customs: (s.scheduleBookings ?? [])
|
||||||
|
.map((sb) => sb.booking)
|
||||||
|
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.customs.length > 0);
|
||||||
|
|
||||||
|
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
||||||
|
const gatepassRows = bookingIds.length
|
||||||
|
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
||||||
|
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
||||||
|
|
||||||
|
return withCustoms.map(({ schedule, customs }) => {
|
||||||
|
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
||||||
|
return {
|
||||||
|
id: schedule.id,
|
||||||
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
routeName: null,
|
||||||
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
|
destination:
|
||||||
|
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||||
|
status: schedule.status,
|
||||||
|
scheduledDepartureDate: schedule.scheduledDepartureDate
|
||||||
|
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||||
|
: null,
|
||||||
|
actualDepartureAt: schedule.actualDepartureAt
|
||||||
|
? new Date(schedule.actualDepartureAt).toISOString()
|
||||||
|
: null,
|
||||||
|
actualArrivalAt: schedule.actualArrivalAt
|
||||||
|
? new Date(schedule.actualArrivalAt).toISOString()
|
||||||
|
: null,
|
||||||
|
freightType:
|
||||||
|
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
||||||
|
customsBookings: customs.map((b) => {
|
||||||
|
const m = gatepassByBooking.get(b.id);
|
||||||
|
const granted = m?.status === 'COMPLETED';
|
||||||
|
return {
|
||||||
|
bookingId: b.id,
|
||||||
|
reference: b.reference ?? b.id,
|
||||||
|
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
||||||
|
contractId: b.contractId ?? null,
|
||||||
|
gatepassGranted: granted,
|
||||||
|
gatepassAt: granted
|
||||||
|
? (m?.metadata?.gatepassAt ??
|
||||||
|
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
||||||
|
* guard failures are collected, not fatal. Import schedules also get the
|
||||||
|
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
||||||
|
*/
|
||||||
|
async grantScheduleGatepass(
|
||||||
|
scheduleId: string,
|
||||||
|
gatepassAt?: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
||||||
|
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
relations: { scheduleBookings: { booking: true } },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
|
||||||
|
const customs = (schedule.scheduleBookings ?? [])
|
||||||
|
.map((sb) => sb.booking)
|
||||||
|
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
||||||
|
if (customs.length === 0) {
|
||||||
|
throw new BadRequestException('No customs bookings ride this schedule.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = 0;
|
||||||
|
const skipped: Array<{ bookingId: string; error: string }> = [];
|
||||||
|
for (const booking of customs) {
|
||||||
|
try {
|
||||||
|
await this.grantGatepass(booking.id, gatepassAt, userId);
|
||||||
|
granted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
skipped.push({
|
||||||
|
bookingId: booking.id,
|
||||||
|
error: e instanceof Error ? e.message : 'Failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
||||||
|
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||||
|
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||||
|
if (!operation) {
|
||||||
|
operation = opRepo.create({ trainScheduleId: scheduleId });
|
||||||
|
}
|
||||||
|
if (!operation.gatepassGrantedAt) {
|
||||||
|
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
||||||
|
await opRepo.save(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { granted, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||||
|
* attached invoice document. The customer pays offline and attaches a slip;
|
||||||
|
* GL (ET or DJ) then confirms to settle it.
|
||||||
|
*/
|
||||||
|
async createFinalInvoice(
|
||||||
|
bookingId: string,
|
||||||
|
input: { amount: number; currency: string; description?: string },
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if (!booking.customsClearingEnabled) {
|
||||||
|
throw new BadRequestException('Final invoice applies to customs bookings only.');
|
||||||
|
}
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException('Invoice amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||||||
|
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const offloaded = milestones.find(
|
||||||
|
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||||||
|
);
|
||||||
|
if (!offloaded) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cargo must be offloaded before the final invoice can be raised.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.billingService.findInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
GL_FINAL_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.status !== Freight.InvoiceStatus.Cancelled &&
|
||||||
|
existing.status !== Freight.InvoiceStatus.Expired
|
||||||
|
) {
|
||||||
|
throw new ConflictException('A final invoice already exists for this shipment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = input.description?.trim() || 'Post-offload charges (Djibouti)';
|
||||||
|
await this.billingService.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: bookingId,
|
||||||
|
type: GL_FINAL_INVOICE_TYPE,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency: input.currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: GL_FINAL_INVOICE_TYPE,
|
||||||
|
description,
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: input.amount,
|
||||||
|
amount: input.amount,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: Freight.InvoiceStatus.Issued,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'final_invoice',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export clearance is administratively done once the final invoice goes out.
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(ContractClearanceCycle)
|
||||||
|
.update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() });
|
||||||
|
|
||||||
|
void userId;
|
||||||
|
const summary = await this.finalInvoiceSummary(bookingId);
|
||||||
|
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer attaches the payment slip for the final invoice. */
|
||||||
|
async uploadFinalInvoiceSlip(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<{ uploaded: boolean }> {
|
||||||
|
await this.getBooking(bookingId);
|
||||||
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||||
|
|
||||||
|
const invoice = await this.billingService.findInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
GL_FINAL_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||||
|
}
|
||||||
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||||
|
throw new BadRequestException('The final invoice is already paid.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||||
|
invoice.status === Freight.InvoiceStatus.Expired
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('The final invoice is no longer payable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'final_invoice_slip',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
return { uploaded: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */
|
||||||
|
async confirmFinalInvoicePaid(
|
||||||
|
bookingId: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||||
|
await this.getBooking(bookingId);
|
||||||
|
const invoice = await this.billingService.findInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
GL_FINAL_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||||
|
}
|
||||||
|
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
if (!files.some((f) => f.code === 'final_invoice_slip')) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The customer has not attached a payment slip yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void userId;
|
||||||
|
const summary = await this.finalInvoiceSummary(bookingId);
|
||||||
|
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL ET advises (or skips) the post-arrival additional duty/tax round (import).
|
||||||
|
* Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
|
||||||
|
*/
|
||||||
|
async adviseSecondDuty(
|
||||||
|
bookingId: string,
|
||||||
|
input: {
|
||||||
|
dutyRequired: boolean;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
declarationSerial?: string;
|
||||||
|
},
|
||||||
|
attachment?: Express.Multer.File,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ advised: boolean; skipped: boolean }> {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if (!booking.customsClearingEnabled) {
|
||||||
|
throw new BadRequestException('Additional duty applies to customs bookings only.');
|
||||||
|
}
|
||||||
|
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||||
|
if (tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Additional duty applies to import shipments only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
|
||||||
|
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);
|
||||||
|
|
||||||
|
if (!input.dutyRequired) {
|
||||||
|
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
|
||||||
|
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||||
|
return { advised: false, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.amount || input.amount <= 0) {
|
||||||
|
throw new BadRequestException('Duty amount must be greater than zero.');
|
||||||
|
}
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
|
||||||
|
if (!attachment && !hasNotice) {
|
||||||
|
throw new BadRequestException('Attach the additional duty/tax notice.');
|
||||||
|
}
|
||||||
|
if (attachment) {
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'duty_tax_notice_2',
|
||||||
|
file: attachment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.milestoneService.completeWithMetadataForBooking(
|
||||||
|
bookingId,
|
||||||
|
'SECOND_DUTY_ADVISED',
|
||||||
|
{
|
||||||
|
dutyAmount: input.amount,
|
||||||
|
dutyCurrency: input.currency ?? 'ETB',
|
||||||
|
declarationSerial: input.declarationSerial,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { advised: true, skipped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer attaches the payment slip for the additional duty round. */
|
||||||
|
async uploadSecondDutySlip(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<{ milestoneCompleted: boolean }> {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||||
|
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||||
|
if (advised?.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException('No additional duty has been advised for this shipment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'duty_tax_receipt_2',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.milestoneService.ensureForBooking(
|
||||||
|
bookingId,
|
||||||
|
'SECOND_DUTY_PAID',
|
||||||
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
|
);
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||||
|
return { milestoneCompleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Second duty round state for clearance views. */
|
||||||
|
secondDutyState(
|
||||||
|
milestones: Array<{
|
||||||
|
milestoneCode: string;
|
||||||
|
status: string;
|
||||||
|
metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
|
||||||
|
}>,
|
||||||
|
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||||
|
): Freight.ClearanceSecondDuty | null {
|
||||||
|
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||||
|
const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
|
||||||
|
if (!advised && !paid) return null;
|
||||||
|
|
||||||
|
const toRef = (code: string) => {
|
||||||
|
const f = files.find((x) => x.code === code);
|
||||||
|
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
advised: advised?.status === 'COMPLETED',
|
||||||
|
skipped: advised?.status === 'SKIPPED',
|
||||||
|
amount: advised?.metadata?.dutyAmount ?? null,
|
||||||
|
currency: advised?.metadata?.dutyCurrency ?? null,
|
||||||
|
declarationSerial: advised?.metadata?.declarationSerial ?? null,
|
||||||
|
noticeFile: toRef('duty_tax_notice_2'),
|
||||||
|
slipFile: toRef('duty_tax_receipt_2'),
|
||||||
|
paid: paid?.status === 'COMPLETED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
||||||
|
async finalInvoiceSummary(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||||||
|
const invoice = await this.billingService.findInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
GL_FINAL_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const toRef = (code: string) => {
|
||||||
|
const f = files.find((x) => x.code === code);
|
||||||
|
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||||
|
};
|
||||||
|
const line = await this.dataSource
|
||||||
|
.getRepository(InvoiceLine)
|
||||||
|
.findOne({ where: { invoiceId: invoice.id } });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
status: invoice.status,
|
||||||
|
totalAmount: Number(invoice.totalAmount),
|
||||||
|
currency: invoice.currency,
|
||||||
|
description: line?.description ?? null,
|
||||||
|
invoiceFile: toRef('final_invoice'),
|
||||||
|
slipFile: toRef('final_invoice_slip'),
|
||||||
|
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes import contracts still on Ethiopia-side clearance only', () => {
|
it('keeps import contracts from the start — DO upload is un-gated', () => {
|
||||||
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
|
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes export contracts with no DJ activity or RO hold', () => {
|
||||||
|
expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||||
bookingWindowStatus!: string;
|
bookingWindowStatus!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-window lifecycle for the one-booking-day cycle
|
||||||
|
* (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE).
|
||||||
|
* NULL on legacy and DOMESTIC schedules — the window engine ignores those.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true })
|
||||||
|
windowPhase?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true })
|
||||||
|
windowOpensAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true })
|
||||||
|
windowClosesAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true })
|
||||||
|
docReviewEndsAt?: Date | null;
|
||||||
|
|
||||||
|
/** Staff finished document review early — starts the batch/payment phase immediately. */
|
||||||
|
@Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
docReviewCompletedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true })
|
||||||
|
paymentPhaseEndsAt?: Date | null;
|
||||||
|
|
||||||
|
/** 1-based count of open→settle cycles run on the booking day. */
|
||||||
|
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
|
||||||
|
bookingCycleNo!: number;
|
||||||
|
|
||||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,66 @@ function windowFromEatStart(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */
|
||||||
|
export function eatDayToUtc(day: string, hour: number, minute = 0): Date {
|
||||||
|
const [year, month, dayOfMonth] = day.split('-').map(Number);
|
||||||
|
return eatToUtc(year, month, dayOfMonth, hour, minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift a `yyyy-MM-dd` EAT day key by whole days. */
|
||||||
|
export function shiftEatDay(day: string, deltaDays: number): string {
|
||||||
|
// Noon UTC keeps the +3h EAT offset from crossing a day boundary.
|
||||||
|
const [year, month, dayOfMonth] = day.split('-').map(Number);
|
||||||
|
const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12));
|
||||||
|
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
shifted.getUTCDate(),
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitialWindowTimes {
|
||||||
|
windowOpensAt: Date;
|
||||||
|
windowClosesAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus
|
||||||
|
* `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its
|
||||||
|
* computed window has fully passed gets a same-day window starting now instead,
|
||||||
|
* capped at departure.
|
||||||
|
*/
|
||||||
|
export function computeImportWindowTimes(
|
||||||
|
departure: Date,
|
||||||
|
cfg: {
|
||||||
|
importWindowLeadDays: number;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowDurationHours: number;
|
||||||
|
},
|
||||||
|
now: Date,
|
||||||
|
): InitialWindowTimes {
|
||||||
|
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||||
|
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||||
|
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||||
|
if (closesAt.getTime() <= now.getTime()) {
|
||||||
|
opensAt = now;
|
||||||
|
closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||||
|
}
|
||||||
|
if (closesAt.getTime() > departure.getTime()) {
|
||||||
|
closesAt = departure;
|
||||||
|
}
|
||||||
|
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
|
||||||
|
export function computeExportWindowTimes(
|
||||||
|
departure: Date,
|
||||||
|
cfg: { exportBookingLeadHours: number },
|
||||||
|
): InitialWindowTimes {
|
||||||
|
return {
|
||||||
|
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
|
||||||
|
windowClosesAt: departure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
||||||
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
||||||
const { year, month, day, hour } = eatParts(date);
|
const { year, month, day, hour } = eatParts(date);
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Tunables for the demand-batching booking → allocation flow.
|
* Tunables for the demand-batching booking → allocation flow.
|
||||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
* Times run in EAT so window boundaries match the local operating clock.
|
||||||
|
*
|
||||||
|
* Cadence and pay-window durations moved to the train_scheduling_global_rules
|
||||||
|
* table (TrainSchedulingService.getWindowConfig) — the window engine
|
||||||
|
* (BookingWindowService) drives all timing off that config.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */
|
|
||||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
|
||||||
// export const BATCH_CRON = '*/3 * * * *';
|
|
||||||
export const BATCH_CRON = '*/5 * * * *';
|
|
||||||
// export const BATCH_CRON = '0 */3 * * *';//
|
|
||||||
|
|
||||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||||
|
|
||||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
|
||||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
||||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
|
||||||
|
|
||||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
let trainSchedulingService: {
|
let trainSchedulingService: {
|
||||||
tryAutoWagonAllocation: jest.Mock;
|
tryAutoWagonAllocation: jest.Mock;
|
||||||
getBookableSchedules: jest.Mock;
|
getBookableSchedules: jest.Mock;
|
||||||
|
getWindowConfig: jest.Mock;
|
||||||
};
|
};
|
||||||
let dataSource: {
|
let dataSource: {
|
||||||
getRepository: jest.Mock;
|
getRepository: jest.Mock;
|
||||||
@@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
violations: [],
|
violations: [],
|
||||||
}),
|
}),
|
||||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||||
|
getWindowConfig: jest.fn().mockResolvedValue({
|
||||||
|
importWindowLeadDays: 3,
|
||||||
|
exportBookingLeadHours: 24,
|
||||||
|
windowOpenHour: 8,
|
||||||
|
windowDurationHours: 3,
|
||||||
|
docReviewMinutes: 30,
|
||||||
|
paymentWindowMinutes: 60,
|
||||||
|
reopenDelayMinutes: 90,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingRepo = {
|
const bookingRepo = {
|
||||||
@@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}) as unknown as Booking;
|
}) as unknown as Booking;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Two OPEN trains on the same route + day, train A earlier than train B.
|
// Two OPEN legacy trains on the same route + day, train A earlier than train B.
|
||||||
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
// fillRouteDay now selects fillable schedules straight from the repository.
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: trainA,
|
id: trainA,
|
||||||
scheduleDate: '2026-06-20T06:00:00.000Z',
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||||
bookingWindowStatus: 'OPEN',
|
bookingWindowStatus: 'OPEN',
|
||||||
|
windowPhase: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: trainB,
|
id: trainB,
|
||||||
scheduleDate: '2026-06-20T09:00:00.000Z',
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'),
|
||||||
bookingWindowStatus: 'OPEN',
|
bookingWindowStatus: 'OPEN',
|
||||||
|
windowPhase: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
Optional,
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
@@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
|
|||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||||
import { Freight } from "@edr/types";
|
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BATCH_CRON,
|
|
||||||
BATCH_TIMEZONE,
|
|
||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_WAGONS_PER_BOOKING,
|
DEFAULT_WAGONS_PER_BOOKING,
|
||||||
PAYMENT_WINDOW_MS,
|
|
||||||
} from "./booking-batch.constants";
|
} from "./booking-batch.constants";
|
||||||
import {
|
import {
|
||||||
bookingTrainLengthMeters,
|
bookingTrainLengthMeters,
|
||||||
@@ -41,6 +39,7 @@ import {
|
|||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
interface Capacity {
|
||||||
@@ -121,6 +120,13 @@ export interface BatchBoardScheduleDetail {
|
|||||||
scheduleDate: string | null;
|
scheduleDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
bookingWindowStatus: string;
|
bookingWindowStatus: string;
|
||||||
|
direction: string | null;
|
||||||
|
windowPhase: string | null;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
bookingCycleNo: number;
|
||||||
locomotive: BatchBoardSchedule["locomotive"];
|
locomotive: BatchBoardSchedule["locomotive"];
|
||||||
capacity: BatchBoardSchedule["capacity"];
|
capacity: BatchBoardSchedule["capacity"];
|
||||||
counts: BatchBoardSchedule["counts"];
|
counts: BatchBoardSchedule["counts"];
|
||||||
@@ -138,6 +144,13 @@ export interface BatchBoardSchedule {
|
|||||||
scheduleDate: string | null;
|
scheduleDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
bookingWindowStatus: string;
|
bookingWindowStatus: string;
|
||||||
|
direction: string | null;
|
||||||
|
windowPhase: string | null;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
bookingCycleNo: number;
|
||||||
locomotive: {
|
locomotive: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -189,6 +202,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
|
@Optional() private readonly splitService?: BookingSplitService,
|
||||||
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -284,11 +298,17 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
/**
|
||||||
|
* Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
|
||||||
|
* schedules with a `windowPhase` are driven exclusively by the window engine
|
||||||
|
* (BookingWindowService), never by the periodic legacy fill.
|
||||||
|
*/
|
||||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||||
const open = await this.trainSchedulesRepository.findAll({
|
const open = (
|
||||||
where: { bookingWindowStatus: "OPEN" },
|
await this.trainSchedulesRepository.findAll({
|
||||||
});
|
where: { bookingWindowStatus: "OPEN" },
|
||||||
|
})
|
||||||
|
).filter((s) => s.windowPhase == null);
|
||||||
const groups = new Map<string, RouteDayGroup>();
|
const groups = new Map<string, RouteDayGroup>();
|
||||||
for (const s of open) {
|
for (const s of open) {
|
||||||
if (!s.scheduledDepartureDate) continue;
|
if (!s.scheduledDepartureDate) continue;
|
||||||
@@ -340,6 +360,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.update(bookingId, { paymentStatus: "PAID" });
|
.update(bookingId, { paymentStatus: "PAID" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paying inside the window accepts an open partial offer — reduce the booking
|
||||||
|
// to the offered part before it boards (remainder returns to the contract cap).
|
||||||
|
if (this.splitService) {
|
||||||
|
await this.splitService.applySplit(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
const linked =
|
const linked =
|
||||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||||
if (!linked) {
|
if (!linked) {
|
||||||
@@ -381,6 +407,99 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.ensurePaidBookingAllocated(bookingId);
|
await this.ensurePaidBookingAllocated(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
||||||
|
async getOpenOfferSummary(bookingId: string): Promise<{
|
||||||
|
offeredWagons: number;
|
||||||
|
totalWagons: number;
|
||||||
|
offeredAmount: number;
|
||||||
|
paymentDeadline: Date;
|
||||||
|
} | null> {
|
||||||
|
if (!this.splitService) return null;
|
||||||
|
const offer = await this.splitService.findOpenOffer(bookingId);
|
||||||
|
if (!offer) return null;
|
||||||
|
return {
|
||||||
|
offeredWagons: offer.offeredWagons,
|
||||||
|
totalWagons: offer.totalWagons,
|
||||||
|
offeredAmount: Number(offer.offeredAmount),
|
||||||
|
paymentDeadline: offer.paymentDeadline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- export FCFS -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||||
|
* Pick the earliest open export train on the booking's corridor/day that still
|
||||||
|
* fits the booking. Throws ConflictException when every train is full — the
|
||||||
|
* staff accept fails and no more export bookings are taken.
|
||||||
|
*/
|
||||||
|
async pickExportSchedule(booking: Booking): Promise<string> {
|
||||||
|
if (!booking.scheduledDate) {
|
||||||
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
|
}
|
||||||
|
const day = eatDay(new Date(booking.scheduledDate));
|
||||||
|
const corridor = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: booking.originYardId,
|
||||||
|
destinationStationId: booking.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: booking.originYardId,
|
||||||
|
destinationStationId: booking.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const candidates = corridor
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === day &&
|
||||||
|
this.isFillable(s),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||||
|
);
|
||||||
|
if (!candidates.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'No export train is accepting bookings for this day',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = await this.loadGlobalRules();
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const need = this.needFor(booking, wagonLengths);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
|
candidate.id,
|
||||||
|
);
|
||||||
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
|
if (!schedule || !locomotive) continue;
|
||||||
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
|
if (this.fits(need, budget)) return schedule.id;
|
||||||
|
}
|
||||||
|
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserve an accepted export booking on its picked train and open the pay
|
||||||
|
* window immediately (payment notification goes out on reserve). Marks the
|
||||||
|
* train FULL when this reservation exhausts the wagon budget.
|
||||||
|
*/
|
||||||
|
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
|
||||||
|
await this.reserve(booking, scheduleId);
|
||||||
|
this.armSettle(scheduleId);
|
||||||
|
const schedule =
|
||||||
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||||
|
await this.setWindow(scheduleId, 'FULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||||
const unlinked =
|
const unlinked =
|
||||||
@@ -393,9 +512,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- cron entry point -----------------------------------------------------
|
// ---- legacy fill entry point ----------------------------------------------
|
||||||
|
|
||||||
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
|
/**
|
||||||
|
* Legacy periodic fill for schedules without a window phase (DOMESTIC and
|
||||||
|
* pre-migration trains). Invoked by BookingWindowService's tick — the old
|
||||||
|
* standalone cron was replaced by the window engine.
|
||||||
|
*/
|
||||||
async runBatchFill(): Promise<void> {
|
async runBatchFill(): Promise<void> {
|
||||||
const groups = await this.openRouteDayGroups();
|
const groups = await this.openRouteDayGroups();
|
||||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||||
@@ -595,6 +718,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null,
|
: null,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
bookingWindowStatus: s.bookingWindowStatus,
|
bookingWindowStatus: s.bookingWindowStatus,
|
||||||
|
direction: s.direction ?? null,
|
||||||
|
windowPhase: s.windowPhase ?? null,
|
||||||
|
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||||
|
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||||
|
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||||
|
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||||
|
? s.paymentPhaseEndsAt.toISOString()
|
||||||
|
: null,
|
||||||
|
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||||
locomotive: loco
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: loco.code,
|
||||||
@@ -679,6 +811,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null,
|
: null,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
bookingWindowStatus: s.bookingWindowStatus,
|
bookingWindowStatus: s.bookingWindowStatus,
|
||||||
|
direction: s.direction ?? null,
|
||||||
|
windowPhase: s.windowPhase ?? null,
|
||||||
|
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||||
|
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||||
|
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||||
|
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||||
|
? s.paymentPhaseEndsAt.toISOString()
|
||||||
|
: null,
|
||||||
|
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||||
locomotive: loco
|
locomotive: loco
|
||||||
? {
|
? {
|
||||||
code: loco.code,
|
code: loco.code,
|
||||||
@@ -722,11 +863,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- core fill ------------------------------------------------------------
|
// ---- core fill ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the batch engine may reserve/allocate onto this schedule right now.
|
||||||
|
* Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
|
||||||
|
* Import window cycle: the engine fills while the customer window is CLOSED —
|
||||||
|
* during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
|
||||||
|
* Export: FCFS while the booking window is open.
|
||||||
|
*/
|
||||||
|
isFillable(schedule: TrainSchedule): boolean {
|
||||||
|
if (schedule.bookingWindowStatus === "FULL") return false;
|
||||||
|
if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
|
||||||
|
if (schedule.direction === "EXPORT") {
|
||||||
|
return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
|
||||||
|
}
|
||||||
|
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
|
||||||
|
}
|
||||||
|
|
||||||
/** Fill one schedule from its priority-ordered pool until full. */
|
/** Fill one schedule from its priority-ordered pool until full. */
|
||||||
async fillSchedule(scheduleId: string): Promise<void> {
|
async fillSchedule(scheduleId: string): Promise<void> {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
|
if (!schedule || !this.isFillable(schedule)) return;
|
||||||
const locomotive = schedule.trainSet?.locomotive;
|
const locomotive = schedule.trainSet?.locomotive;
|
||||||
if (!schedule.trainSetId || !locomotive) {
|
if (!schedule.trainSetId || !locomotive) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -793,22 +950,33 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
destinationYardId: string,
|
destinationYardId: string,
|
||||||
day: string,
|
day: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
// The day's fillable schedules on this exact corridor, earliest first. Fillable
|
||||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
|
||||||
originYardId,
|
// the batch must run while the customer window is closed.
|
||||||
destinationYardId,
|
const corridor = await this.trainSchedulesRepository.findAll({
|
||||||
);
|
where: [
|
||||||
const scheduleIds = bookable
|
{
|
||||||
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const scheduleIds = corridor
|
||||||
.filter(
|
.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.bookingWindowStatus === "OPEN" &&
|
s.scheduledDepartureDate != null &&
|
||||||
s.scheduleDate != null &&
|
eatDay(s.scheduledDepartureDate) === day &&
|
||||||
eatDay(new Date(s.scheduleDate)) === day,
|
this.isFillable(s),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.scheduleDate).getTime() -
|
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||||
new Date(b.scheduleDate).getTime(),
|
|
||||||
)
|
)
|
||||||
.map((s) => s.id);
|
.map((s) => s.id);
|
||||||
|
|
||||||
@@ -870,7 +1038,33 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// Fits no train this day — stays in the pool, retried next batch.
|
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||||
|
// partial-capacity offer on the train with the most free wagons: pay =
|
||||||
|
// accept the split (remainder returns to the contract cap), no pay =
|
||||||
|
// booking stays whole and expires for this train.
|
||||||
|
const partialTarget = [...trains]
|
||||||
|
.filter((t) => t.budget.wagons >= 1)
|
||||||
|
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||||
|
if (
|
||||||
|
partialTarget &&
|
||||||
|
!booking.isGovernment &&
|
||||||
|
booking.tradeDirection === "IMPORT" &&
|
||||||
|
booking.contractKind === "GENERAL" &&
|
||||||
|
this.splitService
|
||||||
|
) {
|
||||||
|
const offered = await this.tryPartialOffer(
|
||||||
|
booking,
|
||||||
|
partialTarget.id,
|
||||||
|
partialTarget.budget,
|
||||||
|
need,
|
||||||
|
);
|
||||||
|
if (offered) {
|
||||||
|
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||||
|
partialTarget.armed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stays in the pool, retried next batch/window cycle.
|
||||||
this.notifier.unplaced(booking, day);
|
this.notifier.unplaced(booking, day);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -893,6 +1087,62 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return trains.map((t) => t.id);
|
return trains.map((t) => t.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||||
|
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||||
|
* meaningful partial fits / an offer is already open.
|
||||||
|
*/
|
||||||
|
private async tryPartialOffer(
|
||||||
|
booking: Booking,
|
||||||
|
scheduleId: string,
|
||||||
|
budget: Capacity,
|
||||||
|
need: Capacity,
|
||||||
|
): Promise<Capacity | null> {
|
||||||
|
if (!this.splitService) return null;
|
||||||
|
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||||
|
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
||||||
|
const sized = await this.splitService.sizeOffer(
|
||||||
|
booking,
|
||||||
|
budget.wagons,
|
||||||
|
need.wagons,
|
||||||
|
bulkCapacityTons,
|
||||||
|
);
|
||||||
|
if (!sized) return null;
|
||||||
|
|
||||||
|
const offeredNeed: Capacity = {
|
||||||
|
wagons: sized.offeredWagons,
|
||||||
|
weightTons: sized.offeredWeightTons,
|
||||||
|
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
|
||||||
|
container: wagonLengths.container,
|
||||||
|
bulk: wagonLengths.bulk,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (!this.fits(offeredNeed, budget)) return null;
|
||||||
|
|
||||||
|
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
|
||||||
|
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
|
||||||
|
// Reserve like a normal batch selection, but the partial invoice + partial
|
||||||
|
// pay-now notification were already produced by createOffer.
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
status: "SELECTED_FOR_BATCH",
|
||||||
|
selectedForBatchAt: new Date(),
|
||||||
|
paymentDeadline: deadline,
|
||||||
|
} as never);
|
||||||
|
booking.trainScheduleId = scheduleId;
|
||||||
|
return offeredNeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBulkWagonCapacityTons(): Promise<number> {
|
||||||
|
const cw3 = await this.dataSource
|
||||||
|
.getRepository(WagonType)
|
||||||
|
.findOne({ where: { code: "CW3" } });
|
||||||
|
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
|
||||||
|
return capacity > 0 ? capacity : 60;
|
||||||
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
const reserved =
|
const reserved =
|
||||||
@@ -1063,7 +1313,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
status: "SELECTED_FOR_BATCH",
|
status: "SELECTED_FOR_BATCH",
|
||||||
@@ -1136,6 +1386,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = null;
|
booking.trainScheduleId = null;
|
||||||
|
// An unpaid partial offer dies with the reservation — the booking stays whole.
|
||||||
|
if (this.splitService) {
|
||||||
|
await this.splitService.expireOpenOffer(booking.id);
|
||||||
|
}
|
||||||
// Pay window closed before settlement → expire the booking's open invoice too
|
// Pay window closed before settlement → expire the booking's open invoice too
|
||||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||||
// source-agnostic.
|
// source-agnostic.
|
||||||
@@ -1359,7 +1613,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return (schedule.maxWagons ?? 0) - used;
|
return (schedule.maxWagons ?? 0) - used;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setWindow(
|
async setWindow(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
status: "OPEN" | "FULL" | "CLOSED",
|
status: "OPEN" | "FULL" | "CLOSED",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -1368,22 +1622,48 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No wagon slots left for allocated + reserved bookings. */
|
||||||
|
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||||
|
const schedule =
|
||||||
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) return false;
|
||||||
|
return (await this.remainingWagons(schedule)) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- timer plumbing -------------------------------------------------------
|
// ---- timer plumbing -------------------------------------------------------
|
||||||
|
|
||||||
|
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||||
|
private async paymentWindowMs(): Promise<number> {
|
||||||
|
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
|
return cfg.paymentWindowMinutes * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
private timeoutName(scheduleId: string): string {
|
private timeoutName(scheduleId: string): string {
|
||||||
return `settle:${scheduleId}`;
|
return `settle:${scheduleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process accelerator only — the durable settle enforcement is the window
|
||||||
|
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
||||||
|
*/
|
||||||
private armSettle(scheduleId: string): void {
|
private armSettle(scheduleId: string): void {
|
||||||
this.removeTimeout(scheduleId);
|
void this.paymentWindowMs()
|
||||||
const handle = setTimeout(() => {
|
.then((delayMs) => {
|
||||||
void this.settleBatch(scheduleId).catch((err) =>
|
this.removeTimeout(scheduleId);
|
||||||
this.logger.error(
|
const handle = setTimeout(() => {
|
||||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
void this.settleBatch(scheduleId).catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, delayMs);
|
||||||
|
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.warn(
|
||||||
|
`armSettle ${scheduleId} skipped: ${(err as Error).message}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, PAYMENT_WINDOW_MS);
|
|
||||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeTimeout(scheduleId: string): void {
|
private removeTimeout(scheduleId: string): void {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingNotifierService {
|
export class BookingNotifierService {
|
||||||
@@ -43,12 +42,32 @@ export class BookingNotifierService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||||
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
|
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||||
await this.notifyContact(b, msg, 'PAY NOW');
|
await this.notifyContact(b, msg, 'PAY NOW');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit
|
||||||
|
* this train. Paying accepts the split; letting the deadline pass keeps the
|
||||||
|
* booking whole and expires it for this train.
|
||||||
|
*/
|
||||||
|
async payNowPartial(
|
||||||
|
b: Booking,
|
||||||
|
deadline: Date,
|
||||||
|
offeredWagons: number,
|
||||||
|
totalWagons: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||||
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
|
const msg =
|
||||||
|
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||||
|
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
||||||
|
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||||
|
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||||
|
}
|
||||||
|
|
||||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||||
reason === 'gov' ? ' (government)' : ''
|
reason === 'gov' ? ' (government)' : ''
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
import {
|
||||||
|
BookingBatchOffer,
|
||||||
|
OfferedLine,
|
||||||
|
} from './entities/booking-batch-offer.entity';
|
||||||
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
|
||||||
|
export interface SizedOffer {
|
||||||
|
offeredWagons: number;
|
||||||
|
totalWagons: number;
|
||||||
|
offeredLines: OfferedLine[] | null;
|
||||||
|
offeredWeightTons: number;
|
||||||
|
offeredAmount: number;
|
||||||
|
offeredPricingBreakdown: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial-capacity booking splits (import batch). The offer is sized and priced
|
||||||
|
* against an in-memory clone — the booking row is untouched until the customer
|
||||||
|
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||||
|
* offer expires and the booking stays whole.
|
||||||
|
*
|
||||||
|
* Only GENERAL-contract commercial bookings are offered partials: the remainder
|
||||||
|
* returns to the contract's quantity cap (derived live from booking_container
|
||||||
|
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||||
|
* any later window within contract validity.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingSplitService {
|
||||||
|
private readonly logger = new Logger(BookingSplitService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
@Inject(forwardRef(() => BookingPricingService))
|
||||||
|
private readonly pricing: BookingPricingService,
|
||||||
|
@Inject(forwardRef(() => BookingInvoiceService))
|
||||||
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly notifier: BookingNotifierService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size the largest part of the booking that fits `freeWagons`, priced via an
|
||||||
|
* in-memory clone. Returns null when nothing meaningful fits (no whole
|
||||||
|
* container unit / no bulk tonnage, or pricing failed).
|
||||||
|
*/
|
||||||
|
async sizeOffer(
|
||||||
|
booking: Booking,
|
||||||
|
freeWagons: number,
|
||||||
|
totalWagons: number,
|
||||||
|
bulkWagonCapacityTons: number,
|
||||||
|
): Promise<SizedOffer | null> {
|
||||||
|
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
|
||||||
|
|
||||||
|
const containers = booking.bookingContainers ?? [];
|
||||||
|
let offeredLines: OfferedLine[] | null = null;
|
||||||
|
let offeredWeightTons = 0;
|
||||||
|
let offeredWagons = 0;
|
||||||
|
const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking);
|
||||||
|
clone.adjustedTotalAmount = null;
|
||||||
|
|
||||||
|
if (containers.length) {
|
||||||
|
offeredLines = [];
|
||||||
|
let remaining = freeWagons;
|
||||||
|
const clonedContainers: BookingContainer[] = [];
|
||||||
|
for (const line of containers) {
|
||||||
|
const quantity = Number(line.quantity ?? 0);
|
||||||
|
const lineWagons = Number(line.wagonsRequired ?? 0);
|
||||||
|
if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue;
|
||||||
|
const perUnit = lineWagons / quantity;
|
||||||
|
// Largest unit count whose wagon need still fits the remaining budget.
|
||||||
|
let take = Math.min(quantity, Math.floor(remaining / perUnit));
|
||||||
|
while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1;
|
||||||
|
if (take <= 0) continue;
|
||||||
|
const takeWagons = Math.ceil(take * perUnit);
|
||||||
|
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
|
||||||
|
offeredLines.push({
|
||||||
|
bookingContainerId: line.id,
|
||||||
|
quantity: take,
|
||||||
|
wagonsRequired: takeWagons,
|
||||||
|
totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000,
|
||||||
|
});
|
||||||
|
offeredWeightTons += take * vgmPerUnit;
|
||||||
|
offeredWagons += takeWagons;
|
||||||
|
remaining -= takeWagons;
|
||||||
|
|
||||||
|
const clonedLine: BookingContainer = Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(line)),
|
||||||
|
line,
|
||||||
|
{
|
||||||
|
quantity: take,
|
||||||
|
wagonsRequired: takeWagons,
|
||||||
|
totalVgmTons: take * vgmPerUnit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
clonedContainers.push(clonedLine);
|
||||||
|
}
|
||||||
|
if (!offeredLines.length || offeredWagons <= 0) return null;
|
||||||
|
clone.bookingContainers = clonedContainers;
|
||||||
|
} else {
|
||||||
|
// Bulk: split by weight — the offered part is what freeWagons can carry.
|
||||||
|
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
|
||||||
|
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
|
||||||
|
if (offeredWeightTons <= 0) return null;
|
||||||
|
offeredWagons = Math.min(
|
||||||
|
freeWagons,
|
||||||
|
Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000;
|
||||||
|
clone.cargoTotalWeightVgm = offeredWeightTons;
|
||||||
|
clone.wagonsRequired = offeredWagons;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const priced = await this.pricing.computePriceForBooking(clone);
|
||||||
|
return {
|
||||||
|
offeredWagons,
|
||||||
|
totalWagons,
|
||||||
|
offeredLines,
|
||||||
|
offeredWeightTons,
|
||||||
|
offeredAmount: priced.totalAmount,
|
||||||
|
offeredPricingBreakdown: {
|
||||||
|
lineItems: priced.lineItems,
|
||||||
|
totalAmount: priced.totalAmount,
|
||||||
|
currency: priced.currency,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
partialOfWagons: totalWagons,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the offer and swap the booking's payable to a partial invoice for the
|
||||||
|
* offered amount. Any previous open offer for the booking is superseded.
|
||||||
|
*/
|
||||||
|
async createOffer(
|
||||||
|
booking: Booking,
|
||||||
|
scheduleId: string,
|
||||||
|
sized: SizedOffer,
|
||||||
|
deadline: Date,
|
||||||
|
): Promise<BookingBatchOffer> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingBatchOffer);
|
||||||
|
await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' });
|
||||||
|
|
||||||
|
// The full-amount invoice must not stay payable next to the partial one.
|
||||||
|
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID');
|
||||||
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(
|
||||||
|
{ ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking,
|
||||||
|
{ dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending },
|
||||||
|
);
|
||||||
|
|
||||||
|
const offer = await repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
offeredWagons: sized.offeredWagons,
|
||||||
|
totalWagons: sized.totalWagons,
|
||||||
|
offeredLines: sized.offeredLines,
|
||||||
|
offeredWeightTons: sized.offeredWeightTons,
|
||||||
|
offeredAmount: sized.offeredAmount,
|
||||||
|
offeredPricingBreakdown: sized.offeredPricingBreakdown,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
paymentDeadline: deadline,
|
||||||
|
status: 'OFFERED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons);
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment received inside the window — the customer accepted the split.
|
||||||
|
* Reduce the booking to the offered lines/weight; the remainder returns to the
|
||||||
|
* contract cap automatically (bookedQuantities derives from live lines).
|
||||||
|
* Idempotent: no OFFERED offer → no-op.
|
||||||
|
*/
|
||||||
|
async applySplit(bookingId: string): Promise<void> {
|
||||||
|
const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({
|
||||||
|
where: { bookingId, status: 'OFFERED' },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
if (!offer) return;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (offer.offeredLines?.length) {
|
||||||
|
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
||||||
|
const lines = await manager.getRepository(BookingContainer).find({
|
||||||
|
where: { bookingId },
|
||||||
|
});
|
||||||
|
for (const line of lines) {
|
||||||
|
const kept = keptByLine.get(line.id);
|
||||||
|
if (!kept) {
|
||||||
|
await manager.getRepository(BookingContainer).softDelete(line.id);
|
||||||
|
await manager
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.softDelete({ bookingContainerId: line.id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dropCount = Number(line.quantity) - kept.quantity;
|
||||||
|
await manager.getRepository(BookingContainer).update(line.id, {
|
||||||
|
quantity: kept.quantity,
|
||||||
|
wagonsRequired: kept.wagonsRequired,
|
||||||
|
totalVgmTons: kept.totalVgmTons,
|
||||||
|
hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity),
|
||||||
|
reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity),
|
||||||
|
});
|
||||||
|
if (dropCount > 0) {
|
||||||
|
// Trim surplus physical units, last-entered first.
|
||||||
|
const units = await manager.getRepository(BookingContainerUnit).find({
|
||||||
|
where: { bookingContainerId: line.id },
|
||||||
|
order: { sortOrder: 'DESC', createdAt: 'DESC' },
|
||||||
|
take: dropCount,
|
||||||
|
});
|
||||||
|
if (units.length) {
|
||||||
|
await manager
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.softDelete(units.map((u) => u.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.getRepository(Booking).update(bookingId, {
|
||||||
|
wagonsRequired: offer.offeredWagons,
|
||||||
|
cargoTotalWeightVgm: offer.offeredWeightTons,
|
||||||
|
totalAmount: offer.offeredAmount,
|
||||||
|
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await manager
|
||||||
|
.getRepository(BookingBatchOffer)
|
||||||
|
.update(offer.id, { status: 'APPLIED' });
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pay window closed without payment — offer dies, booking stays whole. */
|
||||||
|
async expireOpenOffer(bookingId: string): Promise<void> {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(BookingBatchOffer)
|
||||||
|
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
|
||||||
|
return this.dataSource.getRepository(BookingBatchOffer).findOne({
|
||||||
|
where: { bookingId, status: 'OFFERED' },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Booking-window timings sourced from the train_scheduling_global_rules singleton,
|
||||||
|
* with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig).
|
||||||
|
*/
|
||||||
|
export interface BookingWindowConfig {
|
||||||
|
/** Days before departure the single import booking-window day falls on. */
|
||||||
|
importWindowLeadDays: number;
|
||||||
|
/** Hours before departure an export booking becomes acceptable (FCFS). */
|
||||||
|
exportBookingLeadHours: number;
|
||||||
|
/** Local (Africa/Addis_Ababa) hour at which the import window opens. */
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowDurationHours: number;
|
||||||
|
/** Max staff document-review time after the window closes. */
|
||||||
|
docReviewMinutes: number;
|
||||||
|
paymentWindowMinutes: number;
|
||||||
|
/** Delay after window close before reopening when the train is not full. */
|
||||||
|
reopenDelayMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
|
||||||
|
export const WINDOW_PHASES = [
|
||||||
|
'PRE_WINDOW',
|
||||||
|
'OPEN',
|
||||||
|
'DOC_REVIEW',
|
||||||
|
'PAYMENT',
|
||||||
|
'CLOSED_FOR_DAY',
|
||||||
|
'DONE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WindowPhase = (typeof WINDOW_PHASES)[number];
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
import { eatDay } from './batch-window.util';
|
||||||
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
|
||||||
|
* booking window for EXPORT schedules. All state lives in DB timestamps on the
|
||||||
|
* schedule row, so every transition is derived purely from the clock — a restart
|
||||||
|
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
|
||||||
|
*
|
||||||
|
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
|
||||||
|
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
|
||||||
|
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
|
||||||
|
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
|
||||||
|
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
|
||||||
|
* fill (runBatchFill), which this tick invokes every 5th minute.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingWindowService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(BookingWindowService.name);
|
||||||
|
private ticking = false;
|
||||||
|
private tickCount = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.tick().catch((err) =>
|
||||||
|
this.logger.warn(`Boot window tick failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
if (this.ticking) return;
|
||||||
|
this.ticking = true;
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
|
|
||||||
|
const active = (
|
||||||
|
await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{ status: TrainScheduleStatusEnum.Draft },
|
||||||
|
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).filter(
|
||||||
|
(s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const schedule of active) {
|
||||||
|
try {
|
||||||
|
await this.advanceSchedule(schedule, cfg, now);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.settleOverdueReservations();
|
||||||
|
|
||||||
|
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
|
||||||
|
this.tickCount += 1;
|
||||||
|
if (this.tickCount % 5 === 0) {
|
||||||
|
await this.bookingBatchService.runBatchFill();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff finished document review early — start the batch/payment phase now. */
|
||||||
|
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (schedule.windowPhase !== 'DOC_REVIEW') {
|
||||||
|
// Idempotent for the whole route-day group: only DOC_REVIEW schedules move.
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||||
|
// Stamp the whole route-day group so one staff action releases every train
|
||||||
|
// sharing this booking day's pool.
|
||||||
|
const group = (
|
||||||
|
await this.trainSchedulesRepository.findAll({
|
||||||
|
where: {
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).filter(
|
||||||
|
(s) =>
|
||||||
|
s.windowPhase === 'DOC_REVIEW' &&
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate),
|
||||||
|
);
|
||||||
|
for (const s of group) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.update(s.id, { docReviewCompletedAt: now });
|
||||||
|
s.docReviewCompletedAt = now;
|
||||||
|
await this.advanceSchedule(s, cfg, now);
|
||||||
|
}
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
return fresh ?? schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- transitions ------------------------------------------------------------
|
||||||
|
|
||||||
|
private async advanceSchedule(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
cfg: BookingWindowConfig,
|
||||||
|
now: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
// Apply every transition that is due, in order (fast-forwards after downtime).
|
||||||
|
for (let guard = 0; guard < 6; guard += 1) {
|
||||||
|
const advanced =
|
||||||
|
schedule.direction === 'EXPORT'
|
||||||
|
? await this.advanceExport(schedule, now)
|
||||||
|
: await this.advanceImport(schedule, cfg, now);
|
||||||
|
if (!advanced) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */
|
||||||
|
private async advanceExport(schedule: TrainSchedule, now: Date): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
schedule.windowPhase === 'PRE_WINDOW' &&
|
||||||
|
schedule.windowOpensAt &&
|
||||||
|
now >= schedule.windowOpensAt
|
||||||
|
) {
|
||||||
|
await this.setPhase(schedule, {
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingCycleNo: schedule.bookingCycleNo + 1,
|
||||||
|
});
|
||||||
|
if (schedule.bookingWindowStatus !== 'FULL') {
|
||||||
|
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||||
|
schedule.bookingWindowStatus = 'OPEN';
|
||||||
|
}
|
||||||
|
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
schedule.windowPhase === 'OPEN' &&
|
||||||
|
schedule.windowClosesAt &&
|
||||||
|
now >= schedule.windowClosesAt
|
||||||
|
) {
|
||||||
|
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||||
|
if (schedule.bookingWindowStatus === 'OPEN') {
|
||||||
|
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||||
|
schedule.bookingWindowStatus = 'CLOSED';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceImport(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
cfg: BookingWindowConfig,
|
||||||
|
now: Date,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { windowPhase, windowOpensAt, windowClosesAt } = schedule;
|
||||||
|
|
||||||
|
if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
|
||||||
|
await this.setPhase(schedule, {
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingCycleNo: schedule.bookingCycleNo + 1,
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
});
|
||||||
|
if (schedule.bookingWindowStatus !== 'FULL') {
|
||||||
|
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||||
|
schedule.bookingWindowStatus = 'OPEN';
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) {
|
||||||
|
const docReviewEndsAt = new Date(
|
||||||
|
windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000,
|
||||||
|
);
|
||||||
|
await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt });
|
||||||
|
if (schedule.bookingWindowStatus === 'OPEN') {
|
||||||
|
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||||
|
schedule.bookingWindowStatus = 'CLOSED';
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
windowPhase === 'DOC_REVIEW' &&
|
||||||
|
(schedule.docReviewCompletedAt != null ||
|
||||||
|
(schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt))
|
||||||
|
) {
|
||||||
|
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
||||||
|
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||||
|
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||||
|
// (or allocating government) — skipped automatically for everyone who fits
|
||||||
|
// is handled inside the fill (all fit → all reserved → all notified).
|
||||||
|
await this.bookingBatchService.processRouteDay({
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day: eatDay(schedule.scheduledDepartureDate),
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
windowPhase === 'PAYMENT' &&
|
||||||
|
schedule.paymentPhaseEndsAt != null &&
|
||||||
|
now >= schedule.paymentPhaseEndsAt
|
||||||
|
) {
|
||||||
|
await this.bookingBatchService.settleDueReservations(schedule.id);
|
||||||
|
await this.concludeCycle(schedule, cfg, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
|
||||||
|
private async concludeCycle(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
cfg: BookingWindowConfig,
|
||||||
|
now: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const full = await this.bookingBatchService.isScheduleFull(schedule.id);
|
||||||
|
if (full) {
|
||||||
|
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||||
|
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||||
|
await this.tryAutoFinalize(schedule.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closesAt = schedule.windowClosesAt ?? now;
|
||||||
|
const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000);
|
||||||
|
const nextOpensAt = reopenAt > now ? reopenAt : now;
|
||||||
|
let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||||
|
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||||
|
nextClosesAt = schedule.scheduledDepartureDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt);
|
||||||
|
const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate;
|
||||||
|
if (sameBookingDay && beforeDeparture) {
|
||||||
|
await this.setPhase(schedule, {
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
windowOpensAt: nextOpensAt,
|
||||||
|
windowClosesAt: nextClosesAt,
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' });
|
||||||
|
this.logger.log(
|
||||||
|
`Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAutoFinalize(scheduleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.trainSchedulingService.finalizeSchedule(scheduleId);
|
||||||
|
this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`);
|
||||||
|
} catch (err) {
|
||||||
|
// Not DRAFT / no linked bookings yet — staff finalize manually.
|
||||||
|
this.logger.warn(
|
||||||
|
`Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable settle backstop: expire/allocate reservations whose deadline passed. */
|
||||||
|
private async settleOverdueReservations(): Promise<void> {
|
||||||
|
const overdue = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||||
|
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||||
|
.andWhere('b.payment_deadline <= now()')
|
||||||
|
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||||
|
.getRawMany<{ scheduleId: string }>();
|
||||||
|
for (const { scheduleId } of overdue) {
|
||||||
|
try {
|
||||||
|
await this.bookingBatchService.settleDueReservations(scheduleId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Overdue settle failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setPhase(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
patch: Partial<
|
||||||
|
Pick<
|
||||||
|
TrainSchedule,
|
||||||
|
| 'windowPhase'
|
||||||
|
| 'windowOpensAt'
|
||||||
|
| 'windowClosesAt'
|
||||||
|
| 'docReviewEndsAt'
|
||||||
|
| 'docReviewCompletedAt'
|
||||||
|
| 'paymentPhaseEndsAt'
|
||||||
|
| 'bookingCycleNo'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
|
||||||
|
Object.assign(schedule, patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
|
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateTrainSchedulingGlobalRulesDto {
|
export class UpdateTrainSchedulingGlobalRulesDto {
|
||||||
@ApiPropertyOptional({ example: 760 })
|
@ApiPropertyOptional({ example: 760 })
|
||||||
@@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
importWindowLeadDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
exportBookingLeadHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(23)
|
||||||
|
windowOpenHour?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.25)
|
||||||
|
@Max(12)
|
||||||
|
windowDurationHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 30 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
docReviewMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 60 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
paymentWindowMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 90 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
reopenDelayMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
|
||||||
|
export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const;
|
||||||
|
export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number];
|
||||||
|
|
||||||
|
/** One reduced container line of a partial offer (per original booking_container row). */
|
||||||
|
export interface OfferedLine {
|
||||||
|
bookingContainerId: string;
|
||||||
|
/** Units of this line that ride the offered train (≤ original quantity). */
|
||||||
|
quantity: number;
|
||||||
|
wagonsRequired: number;
|
||||||
|
totalVgmTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A partial-capacity payment offer made by the batch when a booking needs more
|
||||||
|
* wagons than the train has left (e.g. needs 20, 3 free). The booking itself is
|
||||||
|
* NOT mutated at offer time — paying inside the window accepts the split
|
||||||
|
* (BookingSplitService.applySplit reduces the booking to the offered lines and
|
||||||
|
* the remainder returns to the contract's quantity cap); letting the deadline
|
||||||
|
* pass expires the offer and the booking stays whole.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_batch_offers' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
@Index(['trainScheduleId'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class BookingBatchOffer extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'train_schedule_id' })
|
||||||
|
trainSchedule?: TrainSchedule;
|
||||||
|
|
||||||
|
@Column({ name: 'offered_wagons', type: 'int' })
|
||||||
|
offeredWagons!: number;
|
||||||
|
|
||||||
|
/** Booking's full wagon need at offer time (for messaging / audit). */
|
||||||
|
@Column({ name: 'total_wagons', type: 'int' })
|
||||||
|
totalWagons!: number;
|
||||||
|
|
||||||
|
/** Reduced container lines (null for bulk offers — bulk splits by weight). */
|
||||||
|
@Column({ name: 'offered_lines', type: 'jsonb', nullable: true })
|
||||||
|
offeredLines?: OfferedLine[] | null;
|
||||||
|
|
||||||
|
@Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 })
|
||||||
|
offeredWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 })
|
||||||
|
offeredAmount!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
|
offeredPricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
/** The partial PREPAID invoice generated for the offered part. */
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||||
|
invoiceId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_deadline', type: 'timestamptz' })
|
||||||
|
paymentDeadline!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' })
|
||||||
|
status!: BookingBatchOfferStatus;
|
||||||
|
}
|
||||||
@@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
|||||||
default: 10,
|
default: 10,
|
||||||
})
|
})
|
||||||
max20ftPairWeightDiffTons!: number;
|
max20ftPairWeightDiffTons!: number;
|
||||||
|
|
||||||
|
/** Days before departure the single import booking-window day falls on. */
|
||||||
|
@Column({ name: 'import_window_lead_days', type: 'int', default: 3 })
|
||||||
|
importWindowLeadDays!: number;
|
||||||
|
|
||||||
|
/** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */
|
||||||
|
@Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 })
|
||||||
|
exportBookingLeadHours!: number;
|
||||||
|
|
||||||
|
/** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */
|
||||||
|
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
||||||
|
windowOpenHour!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'window_duration_hours',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 4,
|
||||||
|
scale: 2,
|
||||||
|
default: 3,
|
||||||
|
})
|
||||||
|
windowDurationHours!: number;
|
||||||
|
|
||||||
|
/** Max time staff have to accept booking documents after the window closes. */
|
||||||
|
@Column({ name: 'doc_review_minutes', type: 'int', default: 30 })
|
||||||
|
docReviewMinutes!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
|
||||||
|
paymentWindowMinutes!: number;
|
||||||
|
|
||||||
|
/** Delay after window close before the window reopens when the train is not yet full. */
|
||||||
|
@Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
|
||||||
|
reopenDelayMinutes!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
|
|||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
|
import { BookingWindowService } from "./booking-window.service";
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
@ApiTags("train-scheduling")
|
@ApiTags("train-scheduling")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -51,8 +53,23 @@ export class TrainSchedulingController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
private readonly bookingWindowService: BookingWindowService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
@Get("my-booking-windows")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Upcoming/open booking windows on the signed-in customer's active contract lanes",
|
||||||
|
})
|
||||||
|
async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
const companyId = await this.billingService.resolveCompanyId(
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
if (!companyId) return [];
|
||||||
|
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("global-rules")
|
@Get("global-rules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||||
@@ -457,6 +474,17 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/doc-review-complete")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
|
||||||
|
})
|
||||||
|
async completeDocReview(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
await this.bookingWindowService.completeDocReview(id);
|
||||||
|
return this.bookingBatchService.getBatchBoardDetail(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("bookings/:bookingId/mark-paid")
|
@Post("bookings/:bookingId/mark-paid")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller';
|
|||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
import { BookingWindowService } from './booking-window.service';
|
||||||
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainSchedulingGlobalRules,
|
TrainSchedulingGlobalRules,
|
||||||
TrainCheckpointEvent,
|
TrainCheckpointEvent,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
|
BookingBatchOffer,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEventsRepository,
|
TrainCheckpointEventsRepository,
|
||||||
BookingBatchService,
|
BookingBatchService,
|
||||||
BookingNotifierService,
|
BookingNotifierService,
|
||||||
|
BookingWindowService,
|
||||||
|
BookingSplitService,
|
||||||
],
|
],
|
||||||
exports: [TrainSchedulingService, BookingBatchService],
|
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
||||||
})
|
})
|
||||||
export class TrainSchedulingModule {}
|
export class TrainSchedulingModule {}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from './dto/import-djibouti-operation.dto';
|
} from './dto/import-djibouti-operation.dto';
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
@@ -98,7 +99,11 @@ import {
|
|||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
} from './booking-batch.constants';
|
} from './booking-batch.constants';
|
||||||
import { eatDay } from './batch-window.util';
|
import {
|
||||||
|
computeExportWindowTimes,
|
||||||
|
computeImportWindowTimes,
|
||||||
|
eatDay,
|
||||||
|
} from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||||
@@ -236,9 +241,37 @@ export class TrainSchedulingService {
|
|||||||
if (dto.max20ftPairWeightDiffTons != null) {
|
if (dto.max20ftPairWeightDiffTons != null) {
|
||||||
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
||||||
}
|
}
|
||||||
|
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||||
|
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||||
|
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||||||
|
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||||
|
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||||
|
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||||
|
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||||||
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
|
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
|
||||||
|
* Numeric columns come back from pg as strings — normalize every field.
|
||||||
|
*/
|
||||||
|
async getWindowConfig(): Promise<BookingWindowConfig> {
|
||||||
|
const row = await this.loadGlobalRulesRow();
|
||||||
|
const num = (v: unknown, fallback: number) => {
|
||||||
|
const n = v == null ? NaN : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||||||
|
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||||||
|
windowOpenHour: num(row?.windowOpenHour, 8),
|
||||||
|
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||||
|
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||||
|
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||||
|
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
||||||
const limits = await this.resolveTrainLimitConfig(dto);
|
const limits = await this.resolveTrainLimitConfig(dto);
|
||||||
return this.buildPreviewResponse(
|
return this.buildPreviewResponse(
|
||||||
@@ -310,8 +343,12 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scheduleWarnings: string[] = [];
|
||||||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||||||
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
|
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
|
||||||
|
// multiple future schedules and does not need to be at the origin yard yet — staff
|
||||||
|
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
|
||||||
|
// everything else surfaces as a warning.
|
||||||
const lockedLocomotives: Locomotive[] = [];
|
const lockedLocomotives: Locomotive[] = [];
|
||||||
for (const locomotiveId of locomotiveIds) {
|
for (const locomotiveId of locomotiveIds) {
|
||||||
const locked = await manager.getRepository(Locomotive).findOne({
|
const locked = await manager.getRepository(Locomotive).findOne({
|
||||||
@@ -321,12 +358,17 @@ export class TrainSchedulingService {
|
|||||||
if (!locked) {
|
if (!locked) {
|
||||||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||||
}
|
}
|
||||||
|
if (locked.status === 'OUT_OF_SERVICE') {
|
||||||
|
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
|
||||||
|
}
|
||||||
if (locked.status !== 'AVAILABLE') {
|
if (locked.status !== 'AVAILABLE') {
|
||||||
throw new ConflictException(`Locomotive ${locked.code} is not available`);
|
scheduleWarnings.push(
|
||||||
|
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (locked.currentYardId !== route.originYardId) {
|
if (locked.currentYardId !== route.originYardId) {
|
||||||
throw new ConflictException(
|
scheduleWarnings.push(
|
||||||
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
|
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lockedLocomotives.push(locked);
|
lockedLocomotives.push(locked);
|
||||||
@@ -340,27 +382,46 @@ export class TrainSchedulingService {
|
|||||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||||
// Effective capacity is capped by the weakest locomotive in the set.
|
// Effective capacity is capped by the weakest locomotive in the set.
|
||||||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||||
|
const departure = new Date(dto.scheduleDate);
|
||||||
|
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
|
||||||
|
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
|
||||||
|
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
|
||||||
|
const windowCfg = await this.getWindowConfig();
|
||||||
|
const windowFields =
|
||||||
|
direction === 'IMPORT'
|
||||||
|
? {
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||||
|
}
|
||||||
|
: direction === 'EXPORT'
|
||||||
|
? {
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
...computeExportWindowTimes(departure, windowCfg),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
const schedule = manager.getRepository(TrainSchedule).create({
|
const schedule = manager.getRepository(TrainSchedule).create({
|
||||||
trainSetId: trainSet.id,
|
trainSetId: trainSet.id,
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
originStationId: route.originYardId,
|
originStationId: route.originYardId,
|
||||||
destinationStationId: route.destinationYardId,
|
destinationStationId: route.destinationYardId,
|
||||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
scheduledDepartureDate: departure,
|
||||||
status: TrainScheduleStatusEnum.Draft,
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
direction,
|
direction,
|
||||||
maxWagons: (
|
maxWagons: (
|
||||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||||
).maxWagonsPerTrain,
|
).maxWagonsPerTrain,
|
||||||
|
...windowFields,
|
||||||
});
|
});
|
||||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||||
await manager.getRepository(Locomotive).update(
|
// Locomotives stay in their current status until dispatch — advance scheduling
|
||||||
{ id: In(lockedLocomotives.map((l) => l.id)) },
|
// must not block the locomotive from serving earlier trains.
|
||||||
{ status: 'ASSIGNED' },
|
|
||||||
);
|
|
||||||
return saved.id;
|
return saved.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.getTrainScheduleById(createdScheduleId);
|
const created = await this.getTrainScheduleById(createdScheduleId);
|
||||||
|
return { ...created, warnings: scheduleWarnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignBookingsToSchedule(
|
async assignBookingsToSchedule(
|
||||||
@@ -778,10 +839,19 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||||
}
|
}
|
||||||
await this.assertImportDjiboutiMayDepart(schedule);
|
await this.assertImportDjiboutiMayDepart(schedule);
|
||||||
|
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||||
|
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||||
|
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
|
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||||
|
if (setLocomotiveIds.length) {
|
||||||
|
await manager
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
|
||||||
|
}
|
||||||
|
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -1461,6 +1531,12 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
/** Open or close a schedule's booking window (staff override). */
|
/** Open or close a schedule's booking window (staff override). */
|
||||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||||
|
if (status === 'OPEN') {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (schedule?.bookingWindowStatus === 'FULL') {
|
||||||
|
throw new ConflictException('Train is full — the booking window cannot be reopened');
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
@@ -1685,16 +1761,14 @@ export class TrainSchedulingService {
|
|||||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (schedule.trainSet?.locomotiveId) {
|
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||||
const loco = await manager
|
// to the destination yard where it physically arrived.
|
||||||
.getRepository(Locomotive)
|
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
|
if (arrivedLocoIds.length) {
|
||||||
if (loco) {
|
await manager.getRepository(Locomotive).update(
|
||||||
await manager.getRepository(Locomotive).update(loco.id, {
|
{ id: In(arrivedLocoIds) },
|
||||||
status: 'AVAILABLE',
|
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||||||
currentYardId: schedule.destinationStationId,
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
@@ -1769,11 +1843,21 @@ export class TrainSchedulingService {
|
|||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||||||
}
|
}
|
||||||
|
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
|
||||||
|
// but never stomp a locomotive that is currently pulling another dispatched train.
|
||||||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
if (cancelledLocoIds.length) {
|
if (cancelledLocoIds.length) {
|
||||||
await manager
|
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
|
||||||
.getRepository(Locomotive)
|
cancelledLocoIds,
|
||||||
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
|
id,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
|
||||||
|
if (releasable.length) {
|
||||||
|
await manager
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||||
if (wagon.physicalWagonId) {
|
if (wagon.physicalWagonId) {
|
||||||
@@ -2016,15 +2100,17 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (assignedLocomotives.length) {
|
if (assignedLocomotives.length) {
|
||||||
// Every locomotive of the set must sit at the origin yard, and the weakest
|
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
|
||||||
// one must still be able to pull the train (min limits across the set).
|
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
||||||
|
// is a hard violation.
|
||||||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||||||
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
||||||
if (offYard) {
|
if (offYard) {
|
||||||
violations.push(
|
warnings.push(
|
||||||
`Locomotive ${offYard.code} is not at the schedule origin yard`,
|
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||||||
);
|
);
|
||||||
} else if (
|
}
|
||||||
|
if (
|
||||||
setLimits &&
|
setLimits &&
|
||||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||||
@@ -2034,21 +2120,22 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const availableLocomotives = (
|
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||||||
await this.locomotivesRepository.findAll({
|
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||||
where: { status: 'AVAILABLE' },
|
});
|
||||||
})
|
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
|
||||||
).filter((l) => l.currentYardId === originYardId);
|
warnings.push(
|
||||||
if (!availableLocomotives.length) {
|
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
|
||||||
violations.push('No available locomotive at the schedule origin yard');
|
);
|
||||||
} else if (
|
}
|
||||||
!availableLocomotives.some(
|
if (
|
||||||
|
!inServiceLocomotives.some(
|
||||||
(l) =>
|
(l) =>
|
||||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
violations.push('No available locomotive can support the total train weight and length');
|
violations.push('No locomotive can support the total train weight and length');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2596,6 +2683,58 @@ export class TrainSchedulingService {
|
|||||||
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
|
||||||
|
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
|
||||||
|
* legacy single-locomotive column on the train set.
|
||||||
|
*/
|
||||||
|
private async findLocomotiveIdsDispatchedElsewhere(
|
||||||
|
locomotiveIds: string[],
|
||||||
|
excludeScheduleId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (!locomotiveIds.length) return new Set();
|
||||||
|
const runner = manager ?? this.dataSource;
|
||||||
|
const rows: { locomotive_id: string }[] = await runner.query(
|
||||||
|
`SELECT DISTINCT loco.locomotive_id
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN (
|
||||||
|
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
WHERE tsl.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT t.id AS train_set_id, t.locomotive_id
|
||||||
|
FROM freight.train_sets t
|
||||||
|
WHERE t.locomotive_id IS NOT NULL
|
||||||
|
) loco ON loco.train_set_id = tset.id
|
||||||
|
WHERE ts.status = 'DISPATCHED'
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND ts.id <> $1
|
||||||
|
AND loco.locomotive_id = ANY($2)`,
|
||||||
|
[excludeScheduleId, locomotiveIds],
|
||||||
|
);
|
||||||
|
return new Set(rows.map((r) => r.locomotive_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertLocomotivesNotDispatchedElsewhere(
|
||||||
|
locomotiveIds: string[],
|
||||||
|
excludeScheduleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
|
||||||
|
locomotiveIds,
|
||||||
|
excludeScheduleId,
|
||||||
|
);
|
||||||
|
if (!busy.size) return;
|
||||||
|
const locos = await this.dataSource
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.find({ where: { id: In([...busy]) } });
|
||||||
|
const codes = locos.map((l) => l.code).join(', ');
|
||||||
|
throw new ConflictException(
|
||||||
|
`Locomotive(s) ${codes} are currently out on another dispatched train`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async selectOrValidateLocomotive(
|
async selectOrValidateLocomotive(
|
||||||
locomotiveId: string,
|
locomotiveId: string,
|
||||||
totalWeightTons: number,
|
totalWeightTons: number,
|
||||||
@@ -2732,15 +2871,113 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** AVAILABLE locomotives at the route's origin yard. */
|
/** AVAILABLE locomotives at the route's origin yard. */
|
||||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
/**
|
||||||
|
* All in-service locomotives, annotated for the schedule-creation picker.
|
||||||
|
* Advance scheduling means nothing is filtered out — staff see status, whether the
|
||||||
|
* locomotive is at the origin yard yet, and how many future schedules it already has.
|
||||||
|
*/
|
||||||
|
async getAvailableLocomotivesForRoute(routeId: string) {
|
||||||
const route = await this.getSchedulableRoute(routeId);
|
const route = await this.getSchedulableRoute(routeId);
|
||||||
|
|
||||||
const locomotives = await this.locomotivesRepository.findAll({
|
const locomotives = await this.locomotivesRepository.findAll({
|
||||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||||
order: { code: 'ASC' },
|
order: { code: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return locomotives;
|
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
|
||||||
|
? await this.dataSource.query(
|
||||||
|
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN (
|
||||||
|
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
WHERE tsl.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT t.id AS train_set_id, t.locomotive_id
|
||||||
|
FROM freight.train_sets t
|
||||||
|
WHERE t.locomotive_id IS NOT NULL
|
||||||
|
) loco ON loco.train_set_id = tset.id
|
||||||
|
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
AND loco.locomotive_id = ANY($1)
|
||||||
|
GROUP BY loco.locomotive_id`,
|
||||||
|
[locomotives.map((l) => l.id)],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
|
||||||
|
|
||||||
|
return locomotives.map((loco) => ({
|
||||||
|
...loco,
|
||||||
|
atOriginYard: loco.currentYardId === route.originYardId,
|
||||||
|
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming/open booking windows for a customer's active-contract lanes —
|
||||||
|
* powers the portal home "booking windows" section. Only window-engine
|
||||||
|
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
|
||||||
|
* always open and need no announcement.
|
||||||
|
*/
|
||||||
|
async getBookingWindowsForCompany(companyId: string) {
|
||||||
|
const rows: Array<{
|
||||||
|
schedule_id: string;
|
||||||
|
direction: string | null;
|
||||||
|
window_phase: string | null;
|
||||||
|
window_opens_at: Date | null;
|
||||||
|
window_closes_at: Date | null;
|
||||||
|
booking_window_status: string;
|
||||||
|
booking_cycle_no: number;
|
||||||
|
scheduled_departure_date: Date;
|
||||||
|
origin_label: string | null;
|
||||||
|
origin_code: string | null;
|
||||||
|
destination_label: string | null;
|
||||||
|
destination_code: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT ts.id AS schedule_id,
|
||||||
|
ts.direction,
|
||||||
|
ts.window_phase,
|
||||||
|
ts.window_opens_at,
|
||||||
|
ts.window_closes_at,
|
||||||
|
ts.booking_window_status,
|
||||||
|
ts.booking_cycle_no,
|
||||||
|
ts.scheduled_departure_date,
|
||||||
|
oy.label AS origin_label, oy.code AS origin_code,
|
||||||
|
dy.label AS destination_label, dy.code AS destination_code
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.contract_routes cr
|
||||||
|
ON cr.origin_yard_id = ts.origin_station_id
|
||||||
|
AND cr.destination_yard_id = ts.destination_station_id
|
||||||
|
AND cr.deleted_at IS NULL
|
||||||
|
JOIN freight.contracts c
|
||||||
|
ON c.id = cr.contract_id
|
||||||
|
AND c.company_id = $1
|
||||||
|
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||||
|
AND ts.window_phase IS NOT NULL
|
||||||
|
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||||
|
AND ts.scheduled_departure_date >= now()
|
||||||
|
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||||
|
[companyId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
scheduleId: r.schedule_id,
|
||||||
|
direction: r.direction,
|
||||||
|
windowPhase: r.window_phase,
|
||||||
|
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||||||
|
windowOpensAt: r.window_opens_at,
|
||||||
|
windowClosesAt: r.window_closes_at,
|
||||||
|
bookingWindowStatus: r.booking_window_status,
|
||||||
|
bookingCycleNo: r.booking_cycle_no,
|
||||||
|
departureDate: r.scheduled_departure_date,
|
||||||
|
origin: r.origin_label ?? r.origin_code ?? null,
|
||||||
|
destination: r.destination_label ?? r.destination_code ?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/dates": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tabler/icons-react": "^3.44.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
|
|||||||
@@ -419,10 +419,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Contract validity",
|
label: "Contract validity",
|
||||||
href: "/dashboard/configuration/contract-validity-periods",
|
href: "/dashboard/configuration/contract-validity-periods",
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// label: "Train scheduling rules",
|
label: "Train scheduling rules",
|
||||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
// },
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -62,10 +62,11 @@ export function GlClearanceUploadModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isDo) {
|
if (isDo) {
|
||||||
|
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
|
||||||
if (isBooking) {
|
if (isBooking) {
|
||||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
|
||||||
} else {
|
} else {
|
||||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
await contractsService.uploadDeliveryOrder(entityId, file, iso);
|
||||||
}
|
}
|
||||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
} else {
|
} else {
|
||||||
@@ -117,7 +118,15 @@ export function GlClearanceUploadModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<DateInput
|
||||||
|
label="Vessel departure date (optional)"
|
||||||
|
value={vesselDate}
|
||||||
|
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||||
|
size="sm"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PhasedFileDropzone
|
<PhasedFileDropzone
|
||||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@ import type {
|
|||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
|
|
||||||
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
||||||
|
import { locomotiveOption, showScheduleWarnings } from "./locomotiveOptions";
|
||||||
import {
|
import {
|
||||||
autoFillPlacements,
|
autoFillPlacements,
|
||||||
mergePlacementsWithSaved,
|
mergePlacementsWithSaved,
|
||||||
@@ -274,6 +275,7 @@ export function AllocateBookingWizard({
|
|||||||
const created = await create.mutateAsync({
|
const created = await create.mutateAsync({
|
||||||
payload: { routeId, scheduleDate, locomotiveIds },
|
payload: { routeId, scheduleDate, locomotiveIds },
|
||||||
});
|
});
|
||||||
|
showScheduleWarnings(created.warnings);
|
||||||
setSelectedScheduleId(created.id);
|
setSelectedScheduleId(created.id);
|
||||||
return created.id;
|
return created.id;
|
||||||
};
|
};
|
||||||
@@ -536,10 +538,9 @@ export function AllocateBookingWizard({
|
|||||||
placeholder={
|
placeholder={
|
||||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||||
}
|
}
|
||||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
data={(locomotivesQuery.data ?? []).map((l) =>
|
||||||
value: l.id,
|
locomotiveOption(l, " · "),
|
||||||
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
)}
|
||||||
}))}
|
|
||||||
value={locomotiveIds}
|
value={locomotiveIds}
|
||||||
onChange={setLocomotiveIds}
|
onChange={setLocomotiveIds}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
import { Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingWindowPhase } from "@/types/trainScheduling";
|
||||||
|
|
||||||
import "./batchVisuals.css";
|
import "./batchVisuals.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,3 +215,69 @@ export function HeroChip({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHASE_META: Record<
|
||||||
|
BookingWindowPhase,
|
||||||
|
{ color: string; label: string; pulse: boolean }
|
||||||
|
> = {
|
||||||
|
PRE_WINDOW: { color: "gray", label: "Pre-window", pulse: false },
|
||||||
|
OPEN: { color: "edr-green", label: "Booking open", pulse: true },
|
||||||
|
DOC_REVIEW: { color: "yellow", label: "Doc review", pulse: true },
|
||||||
|
PAYMENT: { color: "blue", label: "Payment", pulse: true },
|
||||||
|
CLOSED_FOR_DAY: { color: "dark", label: "Closed for day", pulse: false },
|
||||||
|
DONE: { color: "dark", label: "Done", pulse: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import booking-cycle phase pill (OPEN → DOC_REVIEW → PAYMENT → …) with an
|
||||||
|
* optional cycle number. Same visual language as `WindowStatusPill`.
|
||||||
|
*/
|
||||||
|
export function WindowPhasePill({
|
||||||
|
phase,
|
||||||
|
cycleNo,
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
phase: BookingWindowPhase;
|
||||||
|
cycleNo?: number;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}) {
|
||||||
|
const meta = PHASE_META[phase] ?? {
|
||||||
|
color: "gray",
|
||||||
|
label: phase,
|
||||||
|
pulse: false,
|
||||||
|
};
|
||||||
|
const compact = size === "sm";
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={6}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
padding: compact ? "2px 8px" : "4px 11px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `var(--mantine-color-${meta.color}-0)`,
|
||||||
|
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
w={compact ? 6 : 7}
|
||||||
|
h={compact ? 6 : 7}
|
||||||
|
className={meta.pulse ? "bb-pulse-dot" : undefined}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: `var(--mantine-color-${meta.color}-6)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
c={`${meta.color}.8`}
|
||||||
|
style={{ letterSpacing: 0.3, lineHeight: 1, whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
{cycleNo && cycleNo > 1 ? ` · cycle ${cycleNo}` : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import hotToast from "react-hot-toast";
|
||||||
|
|
||||||
|
import type { LocomotiveRecord } from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locomotives can now be scheduled in advance: not-at-origin-yard or
|
||||||
|
* already-on-future-schedules is allowed with a warning (only OUT_OF_SERVICE
|
||||||
|
* is blocked server-side). This returns the hint to surface in the picker,
|
||||||
|
* or null when the locomotive is ready at the origin yard.
|
||||||
|
*/
|
||||||
|
export function locomotiveWarning(loco: LocomotiveRecord): string | null {
|
||||||
|
const hints: string[] = [];
|
||||||
|
if (loco.atOriginYard === false) hints.push("not at origin yard");
|
||||||
|
const futureCount = loco.futureScheduleCount ?? 0;
|
||||||
|
if (futureCount > 0) {
|
||||||
|
hints.push(`on ${futureCount} future schedule${futureCount === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
return hints.length ? hints.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MultiSelect option for the schedule-creation locomotive picker. */
|
||||||
|
export function locomotiveOption(
|
||||||
|
loco: LocomotiveRecord,
|
||||||
|
nameSeparator = " — ",
|
||||||
|
): { value: string; label: string } {
|
||||||
|
const base = `${loco.code}${loco.name ? `${nameSeparator}${loco.name}` : ""}`;
|
||||||
|
const warning = locomotiveWarning(loco);
|
||||||
|
return {
|
||||||
|
value: loco.id,
|
||||||
|
label: warning ? `${base} · ⚠ ${warning}` : base,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yellow toast listing create-schedule warnings (e.g. locomotive not at the
|
||||||
|
* origin yard yet). The shared `useToast` hook only knows success/error, so
|
||||||
|
* this styles a react-hot-toast directly.
|
||||||
|
*/
|
||||||
|
export function showScheduleWarnings(warnings?: string[] | null): void {
|
||||||
|
if (!warnings?.length) return;
|
||||||
|
hotToast(warnings.join("\n"), {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 8000,
|
||||||
|
style: {
|
||||||
|
background: "var(--mantine-color-yellow-0)",
|
||||||
|
color: "var(--mantine-color-yellow-9)",
|
||||||
|
border: "1px solid var(--mantine-color-yellow-4)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ export const QUERY_KEYS = {
|
|||||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||||
clearanceHistory: (region?: string) =>
|
clearanceHistory: (region?: string) =>
|
||||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||||
|
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
|
||||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||||
bookingMilestones: (bookingId: string) =>
|
bookingMilestones: (bookingId: string) =>
|
||||||
|
|||||||
@@ -215,6 +215,17 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/t1-close`,
|
`/contracts/bookings/${bookingId}/t1-close`,
|
||||||
|
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
|
||||||
|
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
|
||||||
|
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
|
||||||
|
BOOKING_GATEPASS: (bookingId: string) =>
|
||||||
|
`/contracts/bookings/${bookingId}/gatepass`,
|
||||||
|
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
||||||
|
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||||
|
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||||
|
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
|
||||||
|
BOOKING_SECOND_DUTY: (bookingId: string) =>
|
||||||
|
`/contracts/bookings/${bookingId}/second-duty`,
|
||||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/incidents`,
|
`/contracts/bookings/${bookingId}/incidents`,
|
||||||
},
|
},
|
||||||
@@ -250,6 +261,8 @@ export const URL_CONSTANTS = {
|
|||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
`/train-scheduling/batch-board/${scheduleId}`,
|
`/train-scheduling/batch-board/${scheduleId}`,
|
||||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||||
|
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export function useDjClearanceQueue(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||||
|
export function useDjClearanceSchedules(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
|
||||||
|
queryFn: () => contractsService.getDjClearanceSchedules(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||||
export function useOpsClearanceQueue(enabled = true) {
|
export function useOpsClearanceQueue(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
@@ -85,6 +86,11 @@ export default function GlClearanceDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const linkedBookingId =
|
||||||
|
data?.kind === "contract" ? (data.clearance.linkedBookingId ?? undefined) : undefined;
|
||||||
|
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||||
|
useBookingMilestones(linkedBookingId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -197,7 +203,13 @@ export default function GlClearanceDetailPage() {
|
|||||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
<PhasedClearanceActionPanel
|
<PhasedClearanceActionPanel
|
||||||
contractId={data.kind === "contract" ? id : undefined}
|
contractId={data.kind === "contract" ? id : undefined}
|
||||||
bookingId={data.kind === "booking" ? id : undefined}
|
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||||
|
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
|
||||||
|
bookingMilestones={
|
||||||
|
data.kind === "booking"
|
||||||
|
? (data.clearance.milestones ?? [])
|
||||||
|
: (bookingMilestones ?? [])
|
||||||
|
}
|
||||||
clearance={data.clearance}
|
clearance={data.clearance}
|
||||||
tradeDirection={data.tradeDirection}
|
tradeDirection={data.tradeDirection}
|
||||||
workflowFiles={workflowFiles}
|
workflowFiles={workflowFiles}
|
||||||
@@ -205,7 +217,10 @@ export default function GlClearanceDetailPage() {
|
|||||||
useUploadModals
|
useUploadModals
|
||||||
onUploadDoRequest={() => setUploadKind("do")}
|
onUploadDoRequest={() => setUploadKind("do")}
|
||||||
onUploadRoRequest={() => setUploadKind("ro")}
|
onUploadRoRequest={() => setUploadKind("ro")}
|
||||||
onChanged={() => void refetch()}
|
onChanged={() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchBookingMilestones();
|
||||||
|
}}
|
||||||
onViewFile={view}
|
onViewFile={view}
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,30 +1,172 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
|
import {
|
||||||
import { ChevronRight, Container, Ship } from "lucide-react";
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
|
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
|
||||||
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
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 {
|
||||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
useDjClearanceQueue,
|
||||||
|
useDjClearanceSchedules,
|
||||||
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
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 schedulesQuery = useDjClearanceSchedules();
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
const bookingItems = bookingQueue ?? [];
|
const scheduleItems = schedulesQuery.data ?? [];
|
||||||
|
|
||||||
|
const [gatepassTarget, setGatepassTarget] =
|
||||||
|
useState<Freight.DjClearanceSchedule | null>(null);
|
||||||
|
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
|
||||||
|
const [granting, setGranting] = useState(false);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
header: "Train",
|
||||||
|
accessorKey: "trainNumber",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{row.original.trainNumber ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Route",
|
||||||
|
id: "route",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">
|
||||||
|
{row.original.origin ?? "—"} → {row.original.destination ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Scheduled departure",
|
||||||
|
id: "scheduled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">
|
||||||
|
{row.original.scheduledDepartureDate
|
||||||
|
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Departed",
|
||||||
|
id: "departed",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">
|
||||||
|
{row.original.actualDepartureAt
|
||||||
|
? new Date(row.original.actualDepartureAt).toLocaleString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Arrived",
|
||||||
|
id: "arrived",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm">
|
||||||
|
{row.original.actualArrivalAt
|
||||||
|
? new Date(row.original.actualArrivalAt).toLocaleString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Status",
|
||||||
|
accessorKey: "status",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
|
||||||
|
{row.original.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Customs bookings",
|
||||||
|
id: "customs",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const bookings = row.original.customsBookings;
|
||||||
|
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Badge variant="light" color="edr-green" radius="sm">
|
||||||
|
{bookings.length}
|
||||||
|
</Badge>
|
||||||
|
{directions.map((d) => (
|
||||||
|
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
|
||||||
|
{d}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Gate pass",
|
||||||
|
id: "gatepass",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const bookings = row.original.customsBookings;
|
||||||
|
const allGranted =
|
||||||
|
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
|
||||||
|
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
|
||||||
|
if (allGranted) {
|
||||||
|
return (
|
||||||
|
<Badge variant="light" color="edr-green" radius="sm">
|
||||||
|
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Truck size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setGatepassAt(new Date());
|
||||||
|
setGatepassTarget(row.original);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gate pass
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
|
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
|
||||||
/>
|
/>
|
||||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||||
<Tabs.List mb="md">
|
<Tabs.List mb="md">
|
||||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||||
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
|
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
|
||||||
|
Schedules ({scheduleItems.length})
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="contracts">
|
<Tabs.Panel value="contracts">
|
||||||
@@ -36,8 +178,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{contractItems.length === 0 ? (
|
{contractItems.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
|
No Djibouti customs contracts yet.
|
||||||
pre-clearance is finalized.
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
contractItems.map((c) => (
|
contractItems.map((c) => (
|
||||||
@@ -73,51 +214,113 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="bookings">
|
<Tabs.Panel value="schedules">
|
||||||
{bookingsLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py={60}>
|
columns={columns}
|
||||||
<Loader color="edr-green" />
|
data={scheduleItems}
|
||||||
</Group>
|
status={
|
||||||
) : (
|
schedulesQuery.isLoading
|
||||||
<Stack gap="sm">
|
? "loading"
|
||||||
{bookingItems.length === 0 ? (
|
: schedulesQuery.isError
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
? "error"
|
||||||
No Djibouti customs bookings yet.
|
: "success"
|
||||||
</Text>
|
}
|
||||||
) : (
|
error={
|
||||||
bookingItems.map((b) => (
|
schedulesQuery.isError
|
||||||
<Card
|
? {
|
||||||
key={b.id}
|
message: "Failed to load train schedules.",
|
||||||
withBorder
|
onRetry: () => void schedulesQuery.refetch(),
|
||||||
radius="md"
|
}
|
||||||
padding="md"
|
: undefined
|
||||||
style={{ cursor: "pointer" }}
|
}
|
||||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
emptyMessage="No train schedules carry customs bookings yet."
|
||||||
>
|
/>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Container 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">
|
|
||||||
Booking
|
|
||||||
</Badge>
|
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={gatepassTarget != null}
|
||||||
|
onClose={() => setGatepassTarget(null)}
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<Truck size={18} />
|
||||||
|
<Text fw={700}>
|
||||||
|
Gate pass — train {gatepassTarget?.trainNumber ?? ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Grants the gate pass for all{" "}
|
||||||
|
{gatepassTarget?.customsBookings.length ?? 0} customs booking
|
||||||
|
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
|
||||||
|
train.
|
||||||
|
</Text>
|
||||||
|
<DateTimePicker
|
||||||
|
label="Gate pass time"
|
||||||
|
value={gatepassAt}
|
||||||
|
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setGatepassTarget(null)}
|
||||||
|
disabled={granting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={granting}
|
||||||
|
leftSection={<Truck size={16} />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!gatepassTarget) return;
|
||||||
|
setGranting(true);
|
||||||
|
try {
|
||||||
|
const result = await contractsService.grantScheduleGatepass(
|
||||||
|
gatepassTarget.id,
|
||||||
|
(gatepassAt ?? new Date()).toISOString(),
|
||||||
|
);
|
||||||
|
if (result.skipped.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setGatepassTarget(null);
|
||||||
|
void schedulesQuery.refetch();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setGranting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Grant gate pass
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "SCHEDULED":
|
||||||
|
return "blue";
|
||||||
|
case "DISPATCHED":
|
||||||
|
return "yellow";
|
||||||
|
case "ARRIVED":
|
||||||
|
return "edr-green";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
BookingPipeline,
|
BookingPipeline,
|
||||||
HeroChip,
|
HeroChip,
|
||||||
totalBookingCount,
|
totalBookingCount,
|
||||||
|
WindowPhasePill,
|
||||||
WindowStatusPill,
|
WindowStatusPill,
|
||||||
} from "@/components/trainScheduling/batchVisuals";
|
} from "@/components/trainScheduling/batchVisuals";
|
||||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||||
@@ -233,7 +234,16 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
<Stack gap={4} align="flex-end">
|
||||||
|
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
||||||
|
{schedule.windowPhase ? (
|
||||||
|
<WindowPhasePill
|
||||||
|
phase={schedule.windowPhase}
|
||||||
|
cycleNo={schedule.bookingCycleNo}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
ClipboardCheck,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
BookingPipeline,
|
BookingPipeline,
|
||||||
HeroChip,
|
HeroChip,
|
||||||
totalBookingCount,
|
totalBookingCount,
|
||||||
|
WindowPhasePill,
|
||||||
WindowStatusPill,
|
WindowStatusPill,
|
||||||
} from "@/components/trainScheduling/batchVisuals";
|
} from "@/components/trainScheduling/batchVisuals";
|
||||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||||
@@ -62,6 +64,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import type {
|
import type {
|
||||||
BatchBoardBookingDetail,
|
BatchBoardBookingDetail,
|
||||||
BatchBoardBookingState,
|
BatchBoardBookingState,
|
||||||
|
BatchBoardScheduleDetail,
|
||||||
BatchWindowGroup,
|
BatchWindowGroup,
|
||||||
BookingAllocationStatus,
|
BookingAllocationStatus,
|
||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
@@ -114,6 +117,61 @@ const fmtDateTime = (iso: string | null) =>
|
|||||||
}).format(new Date(iso))
|
}).format(new Date(iso))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
const eatDayFmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Africa/Addis_Ababa",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "11:00 EAT" if the timestamp falls on today (EAT), else "05 Jun, 11:00 EAT". */
|
||||||
|
const fmtPhaseTime = (iso: string) => {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const time = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Africa/Addis_Ababa",
|
||||||
|
}).format(date);
|
||||||
|
if (eatDayFmt.format(date) === eatDayFmt.format(new Date())) {
|
||||||
|
return `${time} EAT`;
|
||||||
|
}
|
||||||
|
const day = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "Africa/Addis_Ababa",
|
||||||
|
}).format(date);
|
||||||
|
return `${day}, ${time} EAT`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Countdown label for the current booking-cycle phase, e.g. "Closes 11:00 EAT". */
|
||||||
|
function phaseCountdown(data: BatchBoardScheduleDetail): string | null {
|
||||||
|
switch (data.windowPhase) {
|
||||||
|
case "PRE_WINDOW":
|
||||||
|
return data.windowOpensAt
|
||||||
|
? `Opens ${fmtPhaseTime(data.windowOpensAt)}`
|
||||||
|
: null;
|
||||||
|
case "OPEN":
|
||||||
|
return data.windowClosesAt
|
||||||
|
? `Closes ${fmtPhaseTime(data.windowClosesAt)}`
|
||||||
|
: null;
|
||||||
|
case "DOC_REVIEW":
|
||||||
|
return data.docReviewEndsAt
|
||||||
|
? `Doc review ends ${fmtPhaseTime(data.docReviewEndsAt)}`
|
||||||
|
: null;
|
||||||
|
case "PAYMENT":
|
||||||
|
return data.paymentPhaseEndsAt
|
||||||
|
? `Payment ends ${fmtPhaseTime(data.paymentPhaseEndsAt)}`
|
||||||
|
: null;
|
||||||
|
case "CLOSED_FOR_DAY":
|
||||||
|
return data.windowOpensAt
|
||||||
|
? `Reopens ${fmtPhaseTime(data.windowOpensAt)}`
|
||||||
|
: null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initials = (name: string) =>
|
const initials = (name: string) =>
|
||||||
name
|
name
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
@@ -462,6 +520,9 @@ export default function BatchScheduleDetailPage() {
|
|||||||
const runAllocation = useMutation(
|
const runAllocation = useMutation(
|
||||||
api.trainScheduling.runAllocation.mutationOptions(),
|
api.trainScheduling.runAllocation.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const completeDocReview = useMutation(
|
||||||
|
api.trainScheduling.completeDocReview.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const hasAssignedWagons = useMemo(
|
const hasAssignedWagons = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -607,6 +668,24 @@ export default function BatchScheduleDetailPage() {
|
|||||||
);
|
);
|
||||||
const selectedDay = dayGroups[selectedIndex];
|
const selectedDay = dayGroups[selectedIndex];
|
||||||
|
|
||||||
|
const handleCompleteDocReview = () => {
|
||||||
|
completeDocReview
|
||||||
|
.mutateAsync(scheduleId ?? "")
|
||||||
|
.then(() => {
|
||||||
|
toast({
|
||||||
|
title: "Document review complete",
|
||||||
|
description: "Batch is running for this route-day group",
|
||||||
|
});
|
||||||
|
void refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: "Could not complete document review",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleRunAllocation = () => {
|
const handleRunAllocation = () => {
|
||||||
runAllocation
|
runAllocation
|
||||||
.mutateAsync({ scheduleId: scheduleId ?? "" })
|
.mutateAsync({ scheduleId: scheduleId ?? "" })
|
||||||
@@ -641,6 +720,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalBookings = totalBookingCount(data.counts);
|
const totalBookings = totalBookingCount(data.counts);
|
||||||
|
const countdown = phaseCountdown(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer fluid>
|
<PageContainer fluid>
|
||||||
@@ -689,6 +769,12 @@ export default function BatchScheduleDetailPage() {
|
|||||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||||
</Title>
|
</Title>
|
||||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||||
|
{data.windowPhase ? (
|
||||||
|
<WindowPhasePill
|
||||||
|
phase={data.windowPhase}
|
||||||
|
cycleNo={data.bookingCycleNo}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<HeroChip>{data.status}</HeroChip>
|
<HeroChip>{data.status}</HeroChip>
|
||||||
</Group>
|
</Group>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
@@ -717,6 +803,12 @@ export default function BatchScheduleDetailPage() {
|
|||||||
{data.locomotive.maxTrainLengthMeters} m
|
{data.locomotive.maxTrainLengthMeters} m
|
||||||
</HeroChip>
|
</HeroChip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{data.windowPhase ? (
|
||||||
|
<HeroChip icon={<Clock size={12} />}>
|
||||||
|
Cycle {data.bookingCycleNo}
|
||||||
|
{countdown ? ` · ${countdown}` : ""}
|
||||||
|
</HeroChip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -730,6 +822,17 @@ export default function BatchScheduleDetailPage() {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
{data.windowPhase === "DOC_REVIEW" ? (
|
||||||
|
<Button
|
||||||
|
color="yellow"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ClipboardCheck size={16} />}
|
||||||
|
loading={completeDocReview.isPending}
|
||||||
|
onClick={handleCompleteDocReview}
|
||||||
|
>
|
||||||
|
Doc review complete — run batch
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
|
import {
|
||||||
|
locomotiveOption,
|
||||||
|
showScheduleWarnings,
|
||||||
|
} from "@/components/trainScheduling/locomotiveOptions";
|
||||||
import {
|
import {
|
||||||
RouteCorridor,
|
RouteCorridor,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
@@ -110,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
selectedRoute.originYard?.label ??
|
selectedRoute.originYard?.label ??
|
||||||
selectedRoute.originYard?.code ??
|
selectedRoute.originYard?.code ??
|
||||||
"the route origin yard";
|
"the route origin yard";
|
||||||
return `Only locomotives currently at ${originLabel} are shown`;
|
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||||
}, [selectedRoute]);
|
}, [selectedRoute]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -356,6 +360,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
payload: { routeId, scheduleDate, locomotiveIds },
|
payload: { routeId, scheduleDate, locomotiveIds },
|
||||||
});
|
});
|
||||||
toast({ title: "Train schedule created" });
|
toast({ title: "Train schedule created" });
|
||||||
|
showScheduleWarnings(created.warnings);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -554,10 +559,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
placeholder={
|
placeholder={
|
||||||
routeId ? "Select at least two locomotives" : "Select a route first"
|
routeId ? "Select at least two locomotives" : "Select a route first"
|
||||||
}
|
}
|
||||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
|
||||||
value: l.id,
|
|
||||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
|
||||||
}))}
|
|
||||||
value={locomotiveIds}
|
value={locomotiveIds}
|
||||||
onChange={setLocomotiveIds}
|
onChange={setLocomotiveIds}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
|
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
|
||||||
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
|
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
|
||||||
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
|
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
|
||||||
|
importWindowLeadDays: Number(form.importWindowLeadDays),
|
||||||
|
exportBookingLeadHours: Number(form.exportBookingLeadHours),
|
||||||
|
windowOpenHour: Number(form.windowOpenHour),
|
||||||
|
windowDurationHours: Number(form.windowDurationHours),
|
||||||
|
docReviewMinutes: Number(form.docReviewMinutes),
|
||||||
|
paymentWindowMinutes: Number(form.paymentWindowMinutes),
|
||||||
|
reopenDelayMinutes: Number(form.reopenDelayMinutes),
|
||||||
});
|
});
|
||||||
setForm(updated);
|
setForm(updated);
|
||||||
toast({ title: "Train scheduling rules saved" });
|
toast({ title: "Train scheduling rules saved" });
|
||||||
@@ -108,6 +115,87 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card maw={720} mt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<PageHeader
|
||||||
|
title="Booking windows"
|
||||||
|
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Import window lead (days)"
|
||||||
|
description="The single booking day opens this many days before departure"
|
||||||
|
value={form.importWindowLeadDays ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Export booking lead (hours)"
|
||||||
|
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||||
|
value={form.exportBookingLeadHours ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Window open hour (EAT)"
|
||||||
|
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||||
|
value={form.windowOpenHour ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Window duration (hours)"
|
||||||
|
value={form.windowDurationHours ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
||||||
|
}
|
||||||
|
min={0.25}
|
||||||
|
max={12}
|
||||||
|
step={0.25}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Document review (minutes)"
|
||||||
|
description="Max staff time to accept booking documents after the window closes"
|
||||||
|
value={form.docReviewMinutes ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Payment window (minutes)"
|
||||||
|
description="Time a selected customer has to pay before the slot expires"
|
||||||
|
value={form.paymentWindowMinutes ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Reopen delay (minutes)"
|
||||||
|
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||||
|
value={form.reopenDelayMinutes ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
||||||
Save rules
|
Save rules
|
||||||
|
|||||||
@@ -360,6 +360,14 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
completeDocReview: endpoint<string, BatchBoardScheduleDetail>(
|
||||||
|
"train-scheduling",
|
||||||
|
"doc-review-complete",
|
||||||
|
(id) => trainSchedulingService.completeDocReview(id),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
setBookingWindow: endpoint<
|
setBookingWindow: endpoint<
|
||||||
{ id: string; status: "OPEN" | "CLOSED" },
|
{ id: string; status: "OPEN" | "CLOSED" },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
|
|||||||
@@ -362,9 +362,14 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data) as BookingDetail;
|
return unwrap(response.data) as BookingDetail;
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
|
uploadDeliveryOrder: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
vesselDepartureDate?: string,
|
||||||
|
): Promise<BookingDetail> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
|
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -283,9 +283,11 @@ export const contractsService = {
|
|||||||
uploadDeliveryOrder: async (
|
uploadDeliveryOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
vesselDepartureDate?: string,
|
||||||
): Promise<Freight.IContract> => {
|
): Promise<Freight.IContract> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
|
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
@@ -354,6 +356,83 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||||
|
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
|
||||||
|
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
|
||||||
|
return unwrap(response.data) as Freight.DjClearanceSchedule[];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Gate pass for every customs booking on a train schedule (captures time). */
|
||||||
|
grantScheduleGatepass: async (
|
||||||
|
scheduleId: string,
|
||||||
|
gatepassAt?: string,
|
||||||
|
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
|
||||||
|
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
|
||||||
|
gatepassAt,
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as {
|
||||||
|
granted: number;
|
||||||
|
skipped: Array<{ bookingId: string; error: string }>;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Gate pass for a single customs booking (captures time). */
|
||||||
|
grantGatepass: async (
|
||||||
|
bookingId: string,
|
||||||
|
gatepassAt?: string,
|
||||||
|
): Promise<{ bookingId: string; gatepassAt: string }> => {
|
||||||
|
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
|
||||||
|
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
||||||
|
sendFinalInvoice: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: { amount: number; currency: string; description?: string; file: File },
|
||||||
|
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("amount", String(payload.amount));
|
||||||
|
form.append("currency", payload.currency);
|
||||||
|
if (payload.description) form.append("description", payload.description);
|
||||||
|
form.append("file", payload.file);
|
||||||
|
const response = await client.post(C.BOOKING_FINAL_INVOICE(bookingId), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL (ET or DJ) confirms the payment slip — settles the final invoice. */
|
||||||
|
confirmFinalInvoicePaid: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
||||||
|
const response = await client.post(C.BOOKING_FINAL_INVOICE_CONFIRM(bookingId));
|
||||||
|
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */
|
||||||
|
adviseSecondDuty: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: {
|
||||||
|
dutyRequired: boolean;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
declarationSerial?: string;
|
||||||
|
attachment?: File | null;
|
||||||
|
},
|
||||||
|
): Promise<{ advised: boolean; skipped: boolean }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("dutyRequired", String(payload.dutyRequired));
|
||||||
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||||
|
if (payload.currency) form.append("currency", payload.currency);
|
||||||
|
if (payload.declarationSerial)
|
||||||
|
form.append("declarationSerial", payload.declarationSerial);
|
||||||
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||||
|
const response = await client.post(C.BOOKING_SECOND_DUTY(bookingId), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as { advised: boolean; skipped: boolean };
|
||||||
|
},
|
||||||
|
|
||||||
// ── Path A self-clearance (Operations review) ──
|
// ── Path A self-clearance (Operations review) ──
|
||||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
const response = await client.get<PaginatedContracts>(
|
const response = await client.get<PaginatedContracts>(
|
||||||
|
|||||||
@@ -158,6 +158,20 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff finished reviewing documents early — runs the batch immediately
|
||||||
|
* for the schedule's whole route-day group.
|
||||||
|
*/
|
||||||
|
completeDocReview: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<BatchBoardScheduleDetail> => {
|
||||||
|
const response = await client.post<BatchBoardScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.DOC_REVIEW_COMPLETE(scheduleId),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
runAllocation: async (
|
runAllocation: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<WagonAllocationAttemptResult> => {
|
): Promise<WagonAllocationAttemptResult> => {
|
||||||
@@ -494,16 +508,7 @@ export const trainSchedulingService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateGlobalRules: async (
|
updateGlobalRules: async (
|
||||||
payload: Partial<
|
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
||||||
Pick<
|
|
||||||
TrainSchedulingGlobalRules,
|
|
||||||
| "maxTrainLengthMeters"
|
|
||||||
| "maxTrainWeightTons"
|
|
||||||
| "maxWagonsPerTrain"
|
|
||||||
| "max20ftContainerWeightTons"
|
|
||||||
| "max20ftPairWeightDiffTons"
|
|
||||||
>
|
|
||||||
>,
|
|
||||||
): Promise<TrainSchedulingGlobalRules> => {
|
): Promise<TrainSchedulingGlobalRules> => {
|
||||||
const response = await client.patch<TrainSchedulingGlobalRules>(
|
const response = await client.patch<TrainSchedulingGlobalRules>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.GLOBAL_RULES,
|
URL_CONSTANTS.TRAIN_SCHEDULING.GLOBAL_RULES,
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ export interface TrainSchedulingGlobalRules {
|
|||||||
maxWagonsPerTrain: number;
|
maxWagonsPerTrain: number;
|
||||||
max20ftContainerWeightTons: number;
|
max20ftContainerWeightTons: number;
|
||||||
max20ftPairWeightDiffTons: number;
|
max20ftPairWeightDiffTons: number;
|
||||||
|
importWindowLeadDays: number;
|
||||||
|
exportBookingLeadHours: number;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowDurationHours: number;
|
||||||
|
docReviewMinutes: number;
|
||||||
|
paymentWindowMinutes: number;
|
||||||
|
reopenDelayMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainSchedulePreviewResponse {
|
export interface TrainSchedulePreviewResponse {
|
||||||
@@ -136,6 +143,10 @@ export interface LocomotiveRecord {
|
|||||||
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
||||||
currentYardId?: string | null;
|
currentYardId?: string | null;
|
||||||
locomotiveType?: "DIESEL" | "ELECTRIC";
|
locomotiveType?: "DIESEL" | "ELECTRIC";
|
||||||
|
/** Whether the locomotive is currently at the route's origin yard. */
|
||||||
|
atOriginYard?: boolean;
|
||||||
|
/** Number of upcoming schedules this locomotive is already assigned to. */
|
||||||
|
futureScheduleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainScheduleListItem {
|
export interface TrainScheduleListItem {
|
||||||
@@ -183,6 +194,18 @@ export interface BookableSchedule {
|
|||||||
locomotive: { id: string; code: string; name?: string | null } | null;
|
locomotive: { id: string; code: string; name?: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import booking-cycle phase for a schedule's booking window (null for
|
||||||
|
* legacy/DOMESTIC schedules that don't run the one-day cycle).
|
||||||
|
*/
|
||||||
|
export type BookingWindowPhase =
|
||||||
|
| "PRE_WINDOW"
|
||||||
|
| "OPEN"
|
||||||
|
| "DOC_REVIEW"
|
||||||
|
| "PAYMENT"
|
||||||
|
| "CLOSED_FOR_DAY"
|
||||||
|
| "DONE";
|
||||||
|
|
||||||
export type BatchBoardBookingState =
|
export type BatchBoardBookingState =
|
||||||
| "ALLOCATED"
|
| "ALLOCATED"
|
||||||
| "SELECTED_FOR_BATCH"
|
| "SELECTED_FOR_BATCH"
|
||||||
@@ -212,6 +235,13 @@ export interface BatchBoardSchedule {
|
|||||||
scheduleDate: string | null;
|
scheduleDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
bookingWindowStatus: string;
|
bookingWindowStatus: string;
|
||||||
|
direction: string | null;
|
||||||
|
windowPhase: BookingWindowPhase | null;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
bookingCycleNo: number;
|
||||||
locomotive: {
|
locomotive: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -278,6 +308,13 @@ export interface BatchBoardScheduleDetail {
|
|||||||
scheduleDate: string | null;
|
scheduleDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
bookingWindowStatus: string;
|
bookingWindowStatus: string;
|
||||||
|
direction: string | null;
|
||||||
|
windowPhase: BookingWindowPhase | null;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
bookingCycleNo: number;
|
||||||
locomotive: BatchBoardSchedule["locomotive"];
|
locomotive: BatchBoardSchedule["locomotive"];
|
||||||
capacity: BatchBoardSchedule["capacity"];
|
capacity: BatchBoardSchedule["capacity"];
|
||||||
counts: BatchBoardSchedule["counts"];
|
counts: BatchBoardSchedule["counts"];
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||||
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||||
|
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
|
||||||
|
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
|
||||||
|
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>
|
||||||
|
`/api/contracts/bookings/${bookingId}/second-duty-slip`,
|
||||||
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
||||||
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
|
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
|
||||||
BOOKING_REQUEST_CANCEL: (reqId: string) =>
|
BOOKING_REQUEST_CANCEL: (reqId: string) =>
|
||||||
@@ -141,6 +145,7 @@ export const URL_CONSTANTS = {
|
|||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||||
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
||||||
|
MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows",
|
||||||
},
|
},
|
||||||
|
|
||||||
PAYMENTS: {
|
PAYMENTS: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RecentContractsSection,
|
RecentContractsSection,
|
||||||
ShipmentsSection,
|
ShipmentsSection,
|
||||||
StatsSection,
|
StatsSection,
|
||||||
|
UpcomingWindowsSection,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { useMyPortalData } from "./hooks";
|
import { useMyPortalData } from "./hooks";
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ export default function MyPortalPage() {
|
|||||||
dashboard,
|
dashboard,
|
||||||
volumePoints,
|
volumePoints,
|
||||||
maxVolume,
|
maxVolume,
|
||||||
|
bookingWindowsQuery,
|
||||||
|
bookingWindows,
|
||||||
} = useMyPortalData(selectedProfileId ?? undefined);
|
} = useMyPortalData(selectedProfileId ?? undefined);
|
||||||
|
|
||||||
const serviceOptions = companyProfiles.map((p) => ({
|
const serviceOptions = companyProfiles.map((p) => ({
|
||||||
@@ -95,6 +98,13 @@ export default function MyPortalPage() {
|
|||||||
contracts={allContracts}
|
contracts={allContracts}
|
||||||
/> */}
|
/> */}
|
||||||
|
|
||||||
|
{/* Upcoming/open booking windows on the customer's contract lanes —
|
||||||
|
hidden when there is nothing coming up. */}
|
||||||
|
<UpcomingWindowsSection
|
||||||
|
windows={bookingWindows}
|
||||||
|
isLoading={bookingWindowsQuery.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Contracts + shipments side by side — the two primary tables. */}
|
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||||
<Grid align="stretch">
|
<Grid align="stretch">
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowRight, CalendarClock } from "lucide-react";
|
||||||
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
const INK = "#10202F";
|
||||||
|
const MUTED = "#6B7C8E";
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
/** All window times are communicated in East Africa Time. */
|
||||||
|
const TZ = "Africa/Addis_Ababa";
|
||||||
|
|
||||||
|
function fmtDay(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("en-GB", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: TZ,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: TZ,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Thu, 10 Jul · 08:00 – 11:00 EAT" (or a phase label when times are unset). */
|
||||||
|
function windowLabel(w: MyBookingWindow): string {
|
||||||
|
if (w.windowOpensAt && w.windowClosesAt) {
|
||||||
|
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||||
|
w.windowClosesAt,
|
||||||
|
)} EAT`;
|
||||||
|
}
|
||||||
|
if (w.windowOpensAt) {
|
||||||
|
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
|
||||||
|
}
|
||||||
|
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({
|
||||||
|
children,
|
||||||
|
bg,
|
||||||
|
color,
|
||||||
|
border,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
border?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
backgroundColor: bg,
|
||||||
|
color,
|
||||||
|
border: border ? `1px solid ${border}` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"] }) {
|
||||||
|
if (!direction) return null;
|
||||||
|
const isImport = direction === "IMPORT";
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
bg={isImport ? "#EAF1FB" : "#ECF6F1"}
|
||||||
|
color={isImport ? "#2E5B96" : "#0A6F4D"}
|
||||||
|
>
|
||||||
|
{isImport ? "Import" : "Export"}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
|
||||||
|
if (w.isOpenNow) {
|
||||||
|
return (
|
||||||
|
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
|
||||||
|
Open now
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
|
||||||
|
return (
|
||||||
|
<Pill bg="#FEF6E6" color="#B07D14">
|
||||||
|
Opens at {fmtTime(w.windowOpensAt)} EAT
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Pill bg="#F1F5F9" color={MUTED}>
|
||||||
|
Upcoming
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpcomingWindowsSectionProps {
|
||||||
|
windows: MyBookingWindow[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's upcoming/open booking windows on their active-contract
|
||||||
|
* lanes. Import trains open a window on one booking day; export trains open
|
||||||
|
* 24h before departure. Hidden entirely when there is nothing to show.
|
||||||
|
*/
|
||||||
|
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||||
|
windows,
|
||||||
|
isLoading,
|
||||||
|
}: UpcomingWindowsSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Nothing upcoming — keep the dashboard uncluttered.
|
||||||
|
if (!isLoading && windows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding={28}>
|
||||||
|
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
|
||||||
|
<Box>
|
||||||
|
<Text fz={19} fw={800} c="edr-text">
|
||||||
|
Booking Windows
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="edr-muted">
|
||||||
|
Upcoming and open booking windows on your contract lanes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} height={60} radius="md" />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={10}>
|
||||||
|
{windows.map((w) => (
|
||||||
|
<Group
|
||||||
|
key={`${w.scheduleId}-${w.bookingCycleNo}`}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
gap={12}
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`,
|
||||||
|
backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined,
|
||||||
|
cursor: w.isOpenNow ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={
|
||||||
|
w.isOpenNow ? () => navigate("/contracts") : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||||
|
{w.origin ?? "—"}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||||
|
{w.destination ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={5} wrap="nowrap" mt={2}>
|
||||||
|
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz={12} style={{ color: MUTED }} truncate>
|
||||||
|
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
<DirectionBadge direction={w.direction} />
|
||||||
|
<StatusBadge window={w} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -12,4 +12,5 @@ export { ShipmentsSection } from "./ShipmentsSection";
|
|||||||
export { StatKpi } from "./StatKpi";
|
export { StatKpi } from "./StatKpi";
|
||||||
export { StatsSection } from "./StatsSection";
|
export { StatsSection } from "./StatsSection";
|
||||||
export { Stepper } from "./Stepper";
|
export { Stepper } from "./Stepper";
|
||||||
|
export { UpcomingWindowsSection } from "./UpcomingWindowsSection";
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
|
api.companies.getDashboard.queryOptions({ input: selectedProfileId }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Upcoming/open booking windows on the customer's active-contract lanes.
|
||||||
|
// Refetched every minute so "Open now" flips without a manual reload.
|
||||||
|
const bookingWindowsQuery = useQuery(
|
||||||
|
api.bookings.getMyBookingWindows.queryOptions({
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bookingWindows = bookingWindowsQuery.data ?? [];
|
||||||
|
|
||||||
const contractsQuery = useQuery(
|
const contractsQuery = useQuery(
|
||||||
api.contracts.list.queryOptions({
|
api.contracts.list.queryOptions({
|
||||||
input: {
|
input: {
|
||||||
@@ -95,6 +104,8 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
contractsQuery,
|
contractsQuery,
|
||||||
invoicesQuery,
|
invoicesQuery,
|
||||||
|
bookingWindowsQuery,
|
||||||
|
bookingWindows,
|
||||||
allContracts,
|
allContracts,
|
||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
|
|||||||
@@ -125,6 +125,46 @@ function Countdown({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Partial-capacity batch offer ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Present on the booking (status SELECTED_FOR_BATCH) when only part of it fit
|
||||||
|
* the train. Paying accepts the split; not paying keeps the booking whole and
|
||||||
|
* it expires for this train. Local extension — not yet in @edr/types.
|
||||||
|
*/
|
||||||
|
interface ActiveBatchOffer {
|
||||||
|
offeredWagons: number;
|
||||||
|
totalWagons: number;
|
||||||
|
offeredAmount: number;
|
||||||
|
paymentDeadline: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartialOfferNotice({ offer }: { offer: ActiveBatchOffer }) {
|
||||||
|
const remaining = offer.totalWagons - offer.offeredWagons;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
mt={14}
|
||||||
|
p={14}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#FEF6E6",
|
||||||
|
border: "1px solid #F3E2B8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz="13px" fw={800} c="#9A5B00">
|
||||||
|
Partial allocation offer
|
||||||
|
</Text>
|
||||||
|
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||||
|
{offer.offeredWagons} of {offer.totalWagons} wagons fit this train.
|
||||||
|
Paying accepts the split — the remaining {remaining} wagon
|
||||||
|
{remaining === 1 ? "" : "s"} return to your contract to book in a later
|
||||||
|
window. If you don't pay before the deadline, your booking stays
|
||||||
|
whole and can be rebooked next window.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Merged payment panel ─────────────────────────────────────────────────────
|
// ── Merged payment panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +180,7 @@ export function BookingPaymentPanel({
|
|||||||
paying,
|
paying,
|
||||||
showCountdown,
|
showCountdown,
|
||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking & { activeBatchOffer?: ActiveBatchOffer | null };
|
||||||
pricing: Pricing;
|
pricing: Pricing;
|
||||||
onPay?: () => void;
|
onPay?: () => void;
|
||||||
paying?: boolean;
|
paying?: boolean;
|
||||||
@@ -217,6 +257,10 @@ export function BookingPaymentPanel({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{!paid && booking.activeBatchOffer && (
|
||||||
|
<PartialOfferNotice offer={booking.activeBatchOffer} />
|
||||||
|
)}
|
||||||
|
|
||||||
{showCountdown && booking.paymentDeadline && (
|
{showCountdown && booking.paymentDeadline && (
|
||||||
<Box mt={16}>
|
<Box mt={16}>
|
||||||
<Countdown
|
<Countdown
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Combobox,
|
|
||||||
Group,
|
Group,
|
||||||
InputBase,
|
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useCombobox,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Check, MapPin, Search } from "lucide-react";
|
import { Check, MapPin, Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +34,16 @@ interface GeocodeResult {
|
|||||||
lng: number;
|
lng: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single Autocomplete prediction. Coordinates are resolved lazily (only when
|
||||||
|
* the user actually picks the row) via a Place Details lookup, so the fast
|
||||||
|
* "type → see a list" path costs one Autocomplete call, not N geocodes.
|
||||||
|
*/
|
||||||
|
interface PlacePrediction {
|
||||||
|
placeId: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps JavaScript API keys are public client-side keys (lock them down by
|
// Maps JavaScript API keys are public client-side keys (lock them down by
|
||||||
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
||||||
// override the default key without a code change.
|
// override the default key without a code change.
|
||||||
@@ -61,7 +69,7 @@ const MAX_RESULTS = 8;
|
|||||||
const REVERSE_COORD_PRECISION = 4;
|
const REVERSE_COORD_PRECISION = 4;
|
||||||
|
|
||||||
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
|
// Simple in-memory caches keyed by the normalized query / rounded coordinate.
|
||||||
const searchCache = new Map<string, GeocodeResult[]>();
|
const searchCache = new Map<string, PlacePrediction[]>();
|
||||||
const reverseCache = new Map<string, string>();
|
const reverseCache = new Map<string, string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,31 +94,98 @@ async function geocode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forward-geocode a free-text query. Served from cache when possible;
|
* Forward-search a free-text query with the Places Autocomplete service.
|
||||||
* otherwise tried EDR-corridor-first, then global, so local addresses rank
|
*
|
||||||
* highest without the field ever looking "broken".
|
* This is the fix for "search only ever returns the country": the Geocoding API
|
||||||
|
* is an address→coords resolver, not a fuzzy place search, so a partial name
|
||||||
|
* like "Tulu Dimtu" under a `country: ET` restriction collapses to the Ethiopia
|
||||||
|
* centroid (`partial_match: true`). Autocomplete IS the search engine — it
|
||||||
|
* matches towns, kebeles, neighbourhoods and landmarks and returns a ranked
|
||||||
|
* prediction list. Coordinates are resolved later, only for the picked row.
|
||||||
|
*
|
||||||
|
* Country bias (not restriction) keeps EDR-corridor places on top while still
|
||||||
|
* letting a genuinely foreign query through — nothing ever looks "broken".
|
||||||
*/
|
*/
|
||||||
async function searchPlaces(
|
async function searchPlaces(
|
||||||
geocoder: google.maps.Geocoder,
|
service: google.maps.places.AutocompleteService,
|
||||||
|
sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
|
||||||
query: string,
|
query: string,
|
||||||
): Promise<GeocodeResult[]> {
|
): Promise<PlacePrediction[]> {
|
||||||
const key = query.trim().toLowerCase();
|
const key = query.trim().toLowerCase();
|
||||||
const cached = searchCache.get(key);
|
const cached = searchCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// The Geocoder only accepts one country restriction per request, so the
|
const found = await new Promise<PlacePrediction[]>((resolve) => {
|
||||||
// corridor pass fans out to one request per country and merges in order.
|
service.getPlacePredictions(
|
||||||
const perCountry = await Promise.all(
|
{
|
||||||
SEARCH_COUNTRIES.map((country) =>
|
input: query,
|
||||||
geocode(geocoder, { address: query, componentRestrictions: { country } }),
|
// `componentRestrictions` is a hard filter and would re-introduce the
|
||||||
),
|
// "only country matches" failure. Autocomplete has no multi-country
|
||||||
);
|
// restriction anyway, so we bias by region instead and keep it soft.
|
||||||
const local = perCountry.flat().slice(0, MAX_RESULTS);
|
componentRestrictions: { country: SEARCH_COUNTRIES },
|
||||||
const found = local.length > 0 ? local : await geocode(geocoder, { address: query });
|
sessionToken,
|
||||||
|
},
|
||||||
|
(predictions, status) => {
|
||||||
|
if (
|
||||||
|
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||||
|
!predictions
|
||||||
|
) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(
|
||||||
|
predictions.slice(0, MAX_RESULTS).map((p) => ({
|
||||||
|
placeId: p.place_id,
|
||||||
|
displayName: p.description,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (found.length > 0) searchCache.set(key, found);
|
if (found.length > 0) searchCache.set(key, found);
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a picked prediction to its coordinates via Place Details. Runs once
|
||||||
|
* per selection (closes the Autocomplete session), so billing stays on the
|
||||||
|
* cheap Autocomplete-per-session tier rather than per-keystroke geocoding.
|
||||||
|
*/
|
||||||
|
async function resolvePrediction(
|
||||||
|
service: google.maps.places.PlacesService,
|
||||||
|
sessionToken: google.maps.places.AutocompleteSessionToken | undefined,
|
||||||
|
prediction: PlacePrediction,
|
||||||
|
): Promise<GeocodeResult | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
service.getDetails(
|
||||||
|
{
|
||||||
|
placeId: prediction.placeId,
|
||||||
|
fields: ["formatted_address", "name", "geometry"],
|
||||||
|
sessionToken,
|
||||||
|
},
|
||||||
|
(place, status) => {
|
||||||
|
const loc = place?.geometry?.location;
|
||||||
|
if (
|
||||||
|
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||||
|
!loc
|
||||||
|
) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
displayName:
|
||||||
|
place?.formatted_address ||
|
||||||
|
place?.name ||
|
||||||
|
prediction.displayName,
|
||||||
|
lat: loc.lat(),
|
||||||
|
lng: loc.lng(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Reverse-geocode a dropped pin to its nearest address (cached). */
|
/** Reverse-geocode a dropped pin to its nearest address (cached). */
|
||||||
async function reverseGeocode(
|
async function reverseGeocode(
|
||||||
geocoder: google.maps.Geocoder,
|
geocoder: google.maps.Geocoder,
|
||||||
@@ -138,6 +213,28 @@ function useGeocoder(): google.maps.Geocoder | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Places services bundle: predictions + details, once `places` loads. */
|
||||||
|
interface PlacesSearch {
|
||||||
|
autocomplete: google.maps.places.AutocompleteService;
|
||||||
|
details: google.maps.places.PlacesService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily constructs the Places Autocomplete + Details services once the
|
||||||
|
* `places` library loads. `PlacesService` needs a DOM node or map to attach to;
|
||||||
|
* a detached div is the standard headless anchor.
|
||||||
|
*/
|
||||||
|
function usePlacesSearch(): PlacesSearch | null {
|
||||||
|
const placesLib = useMapsLibrary("places");
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!placesLib) return null;
|
||||||
|
return {
|
||||||
|
autocomplete: new placesLib.AutocompleteService(),
|
||||||
|
details: new placesLib.PlacesService(document.createElement("div")),
|
||||||
|
};
|
||||||
|
}, [placesLib]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Recenters the map imperatively when the pinned coordinate changes. */
|
/** Recenters the map imperatively when the pinned coordinate changes. */
|
||||||
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -166,7 +263,7 @@ export interface LocationPickerProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Address + map location picker backed by Google Maps:
|
* Address + map location picker backed by Google Maps:
|
||||||
* - type to search (Geocoding API forward geocoding, debounced),
|
* - type to search (Places Autocomplete, debounced),
|
||||||
* - or click anywhere on the map to drop a pin (reverse geocoding).
|
* - or click anywhere on the map to drop a pin (reverse geocoding).
|
||||||
* Reports the resolved address and coordinates up via `onChange`.
|
* Reports the resolved address and coordinates up via `onChange`.
|
||||||
*/
|
*/
|
||||||
@@ -287,22 +384,29 @@ function LocationPickerInline({
|
|||||||
mapHeight = 260,
|
mapHeight = 260,
|
||||||
withinPortal = true,
|
withinPortal = true,
|
||||||
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
||||||
const combobox = useCombobox();
|
|
||||||
const geocoder = useGeocoder();
|
const geocoder = useGeocoder();
|
||||||
|
const places = usePlacesSearch();
|
||||||
|
const placesLib = useMapsLibrary("places");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<GeocodeResult[]>([]);
|
const [results, setResults] = useState<PlacePrediction[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
|
|
||||||
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
||||||
|
// One Autocomplete session groups every keystroke of a search with the final
|
||||||
|
// Details fetch into a single billable unit. Reset after each pick.
|
||||||
|
const sessionTokenRef = useRef<
|
||||||
|
google.maps.places.AutocompleteSessionToken | undefined
|
||||||
|
>(undefined);
|
||||||
|
if (placesLib && !sessionTokenRef.current) {
|
||||||
|
sessionTokenRef.current = new placesLib.AutocompleteSessionToken();
|
||||||
|
}
|
||||||
|
|
||||||
const hasPin = value.lat != null && value.lng != null;
|
const hasPin = value.lat != null && value.lng != null;
|
||||||
|
|
||||||
// Debounced forward search — fires only after the user stops typing
|
// Debounced forward search — fires only after the user stops typing
|
||||||
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
|
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
|
||||||
// than one per keystroke. The dropdown is kept open the whole time so the
|
// than one per keystroke. While it runs, the input shows a spinner; the
|
||||||
// user sees the "Searching…" state and then the live results for what they
|
// dropdown itself only appears once there are predictions to show.
|
||||||
// typed.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (q.length < MIN_QUERY_LEN) {
|
if (q.length < MIN_QUERY_LEN) {
|
||||||
@@ -311,24 +415,25 @@ function LocationPickerInline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
combobox.openDropdown();
|
if (!places) return; // re-runs once the places library loads
|
||||||
if (!geocoder) return; // re-runs once the geocoding library loads
|
// Autocomplete has no abort support, so a token marks superseded requests
|
||||||
// The Geocoder has no abort support, so a token marks superseded requests
|
|
||||||
// and their responses are dropped instead of overwriting newer results.
|
// and their responses are dropped instead of overwriting newer results.
|
||||||
const token = { stale: false };
|
const token = { stale: false };
|
||||||
searchStaleRef.current = token;
|
|
||||||
const handle = setTimeout(async () => {
|
const handle = setTimeout(async () => {
|
||||||
const found = await searchPlaces(geocoder, q);
|
const found = await searchPlaces(
|
||||||
|
places.autocomplete,
|
||||||
|
sessionTokenRef.current,
|
||||||
|
q,
|
||||||
|
);
|
||||||
if (token.stale) return;
|
if (token.stale) return;
|
||||||
setResults(found);
|
setResults(found);
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
combobox.openDropdown();
|
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
token.stale = true;
|
token.stale = true;
|
||||||
};
|
};
|
||||||
}, [query, geocoder, combobox]);
|
}, [query, places]);
|
||||||
|
|
||||||
// Drop any in-flight reverse lookup when the picker unmounts.
|
// Drop any in-flight reverse lookup when the picker unmounts.
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -339,13 +444,33 @@ function LocationPickerInline({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectResult = useCallback(
|
const selectResult = useCallback(
|
||||||
(r: GeocodeResult) => {
|
async (prediction: PlacePrediction) => {
|
||||||
onChange({ address: r.displayName, lat: r.lat, lng: r.lng });
|
// Clear the query/results immediately so the pending debounce can't fire
|
||||||
|
// a search for the picked address and pop the dropdown back open.
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
combobox.closeDropdown();
|
// Predictions carry no coordinates — resolve them now via Place Details.
|
||||||
|
if (!places) return;
|
||||||
|
setResolving(true);
|
||||||
|
const resolved = await resolvePrediction(
|
||||||
|
places.details,
|
||||||
|
sessionTokenRef.current,
|
||||||
|
prediction,
|
||||||
|
);
|
||||||
|
// A Details fetch closes the Autocomplete billing session; start a fresh
|
||||||
|
// token so the next search is its own session.
|
||||||
|
sessionTokenRef.current = placesLib
|
||||||
|
? new placesLib.AutocompleteSessionToken()
|
||||||
|
: undefined;
|
||||||
|
setResolving(false);
|
||||||
|
if (!resolved) return;
|
||||||
|
onChange({
|
||||||
|
address: resolved.displayName,
|
||||||
|
lat: resolved.lat,
|
||||||
|
lng: resolved.lng,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[onChange, combobox],
|
[onChange, places, placesLib],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePin = useCallback(
|
const handlePin = useCallback(
|
||||||
@@ -383,60 +508,44 @@ function LocationPickerInline({
|
|||||||
? { lat: value.lat as number, lng: value.lng as number }
|
? { lat: value.lat as number, lng: value.lng as number }
|
||||||
: DEFAULT_CENTER;
|
: DEFAULT_CENTER;
|
||||||
|
|
||||||
|
// Mantine Autocomplete requires unique option values; predictions are keyed
|
||||||
|
// by their display text, so de-duplicate the rare identical descriptions.
|
||||||
|
const optionsByName = useMemo(() => {
|
||||||
|
const byName = new Map<string, PlacePrediction>();
|
||||||
|
for (const r of results) {
|
||||||
|
if (!byName.has(r.displayName)) byName.set(r.displayName, r);
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Combobox
|
<Autocomplete
|
||||||
store={combobox}
|
label={label || undefined}
|
||||||
withinPortal={withinPortal}
|
placeholder={placeholder}
|
||||||
shadow="md"
|
value={inputValue}
|
||||||
radius="md"
|
error={error}
|
||||||
>
|
radius={10}
|
||||||
<Combobox.Target>
|
styles={fieldStyles}
|
||||||
<InputBase
|
leftSection={<Search size={16} />}
|
||||||
label={label || undefined}
|
rightSection={searching || resolving ? <Loader size={14} /> : null}
|
||||||
placeholder={placeholder}
|
data={[...optionsByName.keys()]}
|
||||||
value={inputValue}
|
// Predictions are already ranked by the Places API for the typed
|
||||||
error={error}
|
// query; Mantine's default substring filter would hide most of them.
|
||||||
radius={10}
|
filter={({ options }) => options}
|
||||||
styles={fieldStyles}
|
maxDropdownHeight={240}
|
||||||
leftSection={<Search size={16} />}
|
comboboxProps={{ withinPortal, shadow: "md", radius: "md" }}
|
||||||
rightSection={searching || resolving ? <Loader size={14} /> : null}
|
onChange={setQuery}
|
||||||
onChange={(e) => {
|
onOptionSubmit={(name) => {
|
||||||
setQuery(e.currentTarget.value);
|
const prediction = optionsByName.get(name);
|
||||||
combobox.openDropdown();
|
if (prediction) void selectResult(prediction);
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
renderOption={({ option }) => (
|
||||||
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
|
<Text fz={13} lineClamp={2}>
|
||||||
}}
|
{option.value}
|
||||||
/>
|
</Text>
|
||||||
</Combobox.Target>
|
)}
|
||||||
|
/>
|
||||||
<Combobox.Dropdown>
|
|
||||||
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
|
|
||||||
{searching ? (
|
|
||||||
<Combobox.Empty>Searching “{query.trim()}”…</Combobox.Empty>
|
|
||||||
) : results.length === 0 ? (
|
|
||||||
<Combobox.Empty>
|
|
||||||
{query.trim().length < MIN_QUERY_LEN
|
|
||||||
? `Type at least ${MIN_QUERY_LEN} characters`
|
|
||||||
: "No matching places"}
|
|
||||||
</Combobox.Empty>
|
|
||||||
) : (
|
|
||||||
results.map((r, i) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={`${r.lat}-${r.lng}-${i}`}
|
|
||||||
value={String(i)}
|
|
||||||
onClick={() => selectResult(r)}
|
|
||||||
>
|
|
||||||
<Text fz={13} lineClamp={2}>
|
|
||||||
{r.displayName}
|
|
||||||
</Text>
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox.Dropdown>
|
|
||||||
</Combobox>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
mt={10}
|
mt={10}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
|
FileInput,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -174,7 +175,7 @@ export default function ContractDetailPage() {
|
|||||||
!!contract &&
|
!!contract &&
|
||||||
contract.customsClearingEnabled &&
|
contract.customsClearingEnabled &&
|
||||||
contract.contractKind === "ONE_TIME";
|
contract.contractKind === "ONE_TIME";
|
||||||
const { data: clearanceView } = useQuery({
|
const { data: clearanceView, refetch: refetchClearance } = useQuery({
|
||||||
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
||||||
enabled: !!id && (inClearance || isPhasedCustomsClearance),
|
enabled: !!id && (inClearance || isPhasedCustomsClearance),
|
||||||
});
|
});
|
||||||
@@ -583,6 +584,51 @@ export default function ContractDetailPage() {
|
|||||||
<ContractClearanceWorkflowBanner contract={contract} />
|
<ContractClearanceWorkflowBanner contract={contract} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{clearanceView?.riskLevel ? (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
style={{ borderColor: BORDER, background: "#FBFDFC" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Text fw={700} fz={14} c={INK}>
|
||||||
|
Customs risk level
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
|
||||||
|
variant="filled"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{clearanceView.riskLevel}
|
||||||
|
</Badge>
|
||||||
|
{clearanceView.riskAssignedAt ? (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
|
||||||
|
<SecondDutyDueCard
|
||||||
|
duty={clearanceView.secondDuty}
|
||||||
|
bookingId={clearanceView.linkedBookingId}
|
||||||
|
onView={view}
|
||||||
|
onChanged={() => void refetchClearance()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
|
||||||
|
<FinalInvoiceDueCard
|
||||||
|
invoice={clearanceView.finalInvoice}
|
||||||
|
bookingId={clearanceView.linkedBookingId}
|
||||||
|
onView={view}
|
||||||
|
onChanged={() => void refetchClearance()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canUploadClearance && (
|
{canUploadClearance && (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
@@ -1447,3 +1493,293 @@ function FactCell({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
||||||
|
* invoice document; the customer pays offline and attaches the payment slip
|
||||||
|
* here, then GL confirms and the badge flips to PAID.
|
||||||
|
*/
|
||||||
|
function FinalInvoiceDueCard({
|
||||||
|
invoice,
|
||||||
|
bookingId,
|
||||||
|
onView,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
invoice: NonNullable<Freight.ContractClearanceView["finalInvoice"]>;
|
||||||
|
bookingId: string;
|
||||||
|
onView: (file: { name: string; url: string }) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [slip, setSlip] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const paid = invoice.status === "PAID";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{
|
||||||
|
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||||
|
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 3,
|
||||||
|
background: paid ? GREEN : "#E3A93C",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<div>
|
||||||
|
<Group gap={8} align="center">
|
||||||
|
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||||
|
<Text fw={700} fz={15} c={INK}>
|
||||||
|
{paid ? "Final invoice paid" : "Final invoice due"} —{" "}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||||
|
{invoice.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||||
|
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||||
|
</Text>
|
||||||
|
{invoice.description ? (
|
||||||
|
<Text fz={13} c="dimmed" mt={2}>
|
||||||
|
{invoice.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{!paid ? (
|
||||||
|
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||||
|
Pay the amount above and attach your payment slip — Global
|
||||||
|
Logistics will confirm the payment.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack gap="xs" miw={260}>
|
||||||
|
{invoice.invoiceFile ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
onView({
|
||||||
|
name: invoice.invoiceFile!.name,
|
||||||
|
url: invoice.invoiceFile!.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View invoice
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{invoice.slipFile ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
onView({
|
||||||
|
name: invoice.slipFile!.name,
|
||||||
|
url: invoice.slipFile!.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View payment slip
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!paid ? (
|
||||||
|
<>
|
||||||
|
<FileInput
|
||||||
|
placeholder={
|
||||||
|
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
|
||||||
|
}
|
||||||
|
value={slip}
|
||||||
|
onChange={setSlip}
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
loading={uploading}
|
||||||
|
disabled={!slip}
|
||||||
|
leftSection={<Upload size={15} />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!slip) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
|
||||||
|
setSlip(null);
|
||||||
|
toast.success("Payment slip attached");
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Upload failed",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOMS_RISK_COLOR: Record<string, string> = {
|
||||||
|
GREEN: "green",
|
||||||
|
YELLOW: "yellow",
|
||||||
|
RED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
||||||
|
* with a notice; the customer pays offline and attaches another slip here.
|
||||||
|
*/
|
||||||
|
function SecondDutyDueCard({
|
||||||
|
duty,
|
||||||
|
bookingId,
|
||||||
|
onView,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
|
||||||
|
bookingId: string;
|
||||||
|
onView: (file: { name: string; url: string }) => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [slip, setSlip] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const paid = duty.paid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{
|
||||||
|
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
||||||
|
background: paid ? "#F6FBF8" : "#FFFBF2",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 3,
|
||||||
|
background: paid ? GREEN : "#E3A93C",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<div>
|
||||||
|
<Group gap={8} align="center">
|
||||||
|
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||||
|
<Text fw={700} fz={15} c={INK}>
|
||||||
|
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
|
||||||
|
</Text>
|
||||||
|
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||||
|
{paid ? "PAID" : "DUE"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||||
|
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
|
||||||
|
</Text>
|
||||||
|
{duty.declarationSerial ? (
|
||||||
|
<Text fz={13} c="dimmed" mt={2}>
|
||||||
|
Payment code: {duty.declarationSerial}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{!paid ? (
|
||||||
|
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||||
|
Customs advised additional duty/tax after arrival. Pay the amount
|
||||||
|
above and attach your payment slip.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack gap="xs" miw={260}>
|
||||||
|
{duty.noticeFile ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View duty notice
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{duty.slipFile ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View payment slip
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!paid ? (
|
||||||
|
<>
|
||||||
|
<FileInput
|
||||||
|
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
|
||||||
|
value={slip}
|
||||||
|
onChange={setSlip}
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
loading={uploading}
|
||||||
|
disabled={!slip}
|
||||||
|
leftSection={<Upload size={15} />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!slip) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadSecondDutySlip(bookingId, slip);
|
||||||
|
setSlip(null);
|
||||||
|
toast.success("Payment slip attached");
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||||
import { filterBookableServices } from "./helpers";
|
import { filterBookableServices, operationToTradeDirection } from "./helpers";
|
||||||
import { fieldStyles, StepLabel } from "./shared";
|
import { fieldStyles, StepLabel } from "./shared";
|
||||||
import { PaymentCurrencyField } from "./payment-currency-field";
|
import { PaymentCurrencyField } from "./payment-currency-field";
|
||||||
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
|
||||||
@@ -241,8 +241,18 @@ export function Step2ServiceType({
|
|||||||
(s) => s.id === serviceTypeId,
|
(s) => s.id === serviceTypeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { includesCustoms, includesFirstMile, includesLastMile } =
|
const { includesCustoms } = serviceType ?? {};
|
||||||
serviceType ?? {};
|
|
||||||
|
// Import contracts never truck the first mile (goods arrive at the port);
|
||||||
|
// export contracts never truck the last mile. Hide the irrelevant toggle by
|
||||||
|
// trade direction, regardless of what the service bundles.
|
||||||
|
const tradeDirection = operationType
|
||||||
|
? operationToTradeDirection(operationType)
|
||||||
|
: null;
|
||||||
|
const includesFirstMile =
|
||||||
|
(serviceType?.includesFirstMile ?? false) && tradeDirection !== "IMPORT";
|
||||||
|
const includesLastMile =
|
||||||
|
(serviceType?.includesLastMile ?? false) && tradeDirection !== "EXPORT";
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
|
|
||||||
@@ -290,6 +300,26 @@ export function Step2ServiceType({
|
|||||||
}
|
}
|
||||||
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
||||||
|
|
||||||
|
// A hidden mile must not leak a stale enabled=true into the payload. The
|
||||||
|
// effect above only fires on service change; switching operation type (import
|
||||||
|
// ⇄ export) hides a mile without touching the service, so clear it here too.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!includesFirstMile && form.getValues("firstMile.enabled")) {
|
||||||
|
form.setValue(
|
||||||
|
"firstMile",
|
||||||
|
{ enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!includesLastMile && form.getValues("lastMile.enabled")) {
|
||||||
|
form.setValue(
|
||||||
|
"lastMile",
|
||||||
|
{ enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [includesFirstMile, includesLastMile, form]);
|
||||||
|
|
||||||
const showServiceSections =
|
const showServiceSections =
|
||||||
serviceType != null || includesFirstMile || includesLastMile;
|
serviceType != null || includesFirstMile || includesLastMile;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
type CustomerTruckAssignmentPayload,
|
type CustomerTruckAssignmentPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
|
type MyBookingWindow,
|
||||||
SubmitBookingResponse,
|
SubmitBookingResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
import {
|
import {
|
||||||
@@ -358,6 +359,12 @@ export const api = {
|
|||||||
"availableDaysForCargo",
|
"availableDaysForCargo",
|
||||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
(input) => bookingsService.getAvailableDaysForCargo(input),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
|
"train-scheduling",
|
||||||
|
"myBookingWindows",
|
||||||
|
() => bookingsService.getMyBookingWindows(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
contracts: {
|
contracts: {
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ export interface PriceLineItem {
|
|||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An upcoming/open booking window on one of the signed-in customer's
|
||||||
|
* active-contract lanes. Import trains open a window on one booking day;
|
||||||
|
* export trains open 24h before departure (first come, first served).
|
||||||
|
*/
|
||||||
|
export interface MyBookingWindow {
|
||||||
|
scheduleId: string;
|
||||||
|
direction: "IMPORT" | "EXPORT" | null;
|
||||||
|
windowPhase: string | null;
|
||||||
|
isOpenNow: boolean;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
bookingWindowStatus: string;
|
||||||
|
bookingCycleNo: number;
|
||||||
|
departureDate: string;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GeneratePriceResponse {
|
export interface GeneratePriceResponse {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
@@ -341,4 +360,15 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return (data.data as Freight.AvailableDaysResponse).days;
|
return (data.data as Freight.AvailableDaysResponse).days;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||||
|
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||||
|
*/
|
||||||
|
getMyBookingWindows: async (): Promise<MyBookingWindow[]> => {
|
||||||
|
const { data } = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS,
|
||||||
|
);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -318,4 +318,34 @@ export const contractsService = {
|
|||||||
});
|
});
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Customer attaches the payment slip for the GL final invoice (export). */
|
||||||
|
uploadFinalInvoiceSlip: async (
|
||||||
|
bookingId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<{ uploaded: boolean }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const { data } = await client.post(
|
||||||
|
C.BOOKING_FINAL_INVOICE_SLIP(bookingId),
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Customer attaches the slip for the post-arrival additional duty round (import). */
|
||||||
|
uploadSecondDutySlip: async (
|
||||||
|
bookingId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<{ milestoneCompleted: boolean }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const { data } = await client.post(
|
||||||
|
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
|
|||||||
{ code: "ex8", label: "EX8 Declaration", uploadedBy: "gl_et", category: "declaration", tradeDirection: "EXPORT" },
|
{ code: "ex8", label: "EX8 Declaration", uploadedBy: "gl_et", category: "declaration", tradeDirection: "EXPORT" },
|
||||||
{ code: "duty_tax_notice", label: "Duty / Tax Notice", uploadedBy: "gl_et", category: "duty" },
|
{ code: "duty_tax_notice", label: "Duty / Tax Notice", uploadedBy: "gl_et", category: "duty" },
|
||||||
{ code: "duty_tax_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
|
{ code: "duty_tax_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
|
||||||
|
{
|
||||||
|
code: "duty_tax_notice_2",
|
||||||
|
label: "Additional Duty / Tax Notice",
|
||||||
|
uploadedBy: "gl_et",
|
||||||
|
category: "duty",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "duty_tax_receipt_2",
|
||||||
|
label: "Additional Duty / Tax Payment Slip",
|
||||||
|
uploadedBy: "customer",
|
||||||
|
category: "duty",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "import_release",
|
||||||
|
label: "Import Release",
|
||||||
|
uploadedBy: "gl_et",
|
||||||
|
category: "declaration",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
},
|
||||||
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit", tradeDirection: "IMPORT" },
|
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit", tradeDirection: "IMPORT" },
|
||||||
{
|
{
|
||||||
code: "export_transport_document",
|
code: "export_transport_document",
|
||||||
@@ -40,6 +61,20 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
|
|||||||
category: "djibouti",
|
category: "djibouti",
|
||||||
tradeDirection: "IMPORT",
|
tradeDirection: "IMPORT",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: "final_invoice",
|
||||||
|
label: "Final Invoice",
|
||||||
|
uploadedBy: "gl_dj",
|
||||||
|
category: "djibouti",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "final_invoice_slip",
|
||||||
|
label: "Final Invoice Payment Slip",
|
||||||
|
uploadedBy: "customer",
|
||||||
|
category: "djibouti",
|
||||||
|
tradeDirection: "EXPORT",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Legacy single-type declaration codes (still shown when already uploaded). */
|
/** Legacy single-type declaration codes (still shown when already uploaded). */
|
||||||
|
|||||||
@@ -256,6 +256,73 @@ export interface ClearanceT1State {
|
|||||||
closedAt?: string | null;
|
closedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Train link state for the booking tied to a customs clearance flow. */
|
||||||
|
export interface ClearanceTrainState {
|
||||||
|
wagonAllocated: boolean;
|
||||||
|
departedAt: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Billing invoice `type` for the GL Djibouti post-offload final invoice. */
|
||||||
|
export const GL_FINAL_INVOICE_TYPE = "GL_FINAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-offload final invoice raised by GL Djibouti: customer pays offline and
|
||||||
|
* attaches a slip; GL (ET or DJ) confirms to mark it paid.
|
||||||
|
*/
|
||||||
|
export interface ClearanceFinalInvoiceSummary {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
status: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
description?: string | null;
|
||||||
|
invoiceFile: { id: string; name: string; url: string } | null;
|
||||||
|
slipFile: { id: string; name: string; url: string } | null;
|
||||||
|
confirmedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-arrival second duty/tax round (import): GL ET advises an additional
|
||||||
|
* amount with a notice; the customer attaches another payment slip. Optional —
|
||||||
|
* GL ET may mark it skipped when no further duty applies.
|
||||||
|
*/
|
||||||
|
export interface ClearanceSecondDuty {
|
||||||
|
advised: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
declarationSerial?: string | null;
|
||||||
|
noticeFile: { id: string; name: string; url: string } | null;
|
||||||
|
slipFile: { id: string; name: string; url: string } | null;
|
||||||
|
paid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
|
||||||
|
export interface DjClearanceScheduleBooking {
|
||||||
|
bookingId: string;
|
||||||
|
reference: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
contractId: string | null;
|
||||||
|
gatepassGranted: boolean;
|
||||||
|
gatepassAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Train schedule row for the GL Djibouti gate-pass table. */
|
||||||
|
export interface DjClearanceSchedule {
|
||||||
|
id: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
routeName: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
status: string;
|
||||||
|
scheduledDepartureDate: string | null;
|
||||||
|
actualDepartureAt: string | null;
|
||||||
|
actualArrivalAt: string | null;
|
||||||
|
freightType: string | null;
|
||||||
|
customsBookings: DjClearanceScheduleBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContractClearanceView {
|
export interface ContractClearanceView {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
||||||
@@ -299,6 +366,21 @@ export interface ContractClearanceView {
|
|||||||
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
||||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
|
/** Train link state for the booking (both directions; null until a booking is linked). */
|
||||||
|
train?: ClearanceTrainState | null;
|
||||||
|
gatepassGranted?: boolean;
|
||||||
|
gatepassAt?: string | null;
|
||||||
|
t1Closed?: boolean;
|
||||||
|
t1ClosedAt?: string | null;
|
||||||
|
offloaded?: boolean;
|
||||||
|
/** GL Djibouti post-offload final invoice (export). */
|
||||||
|
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||||
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
|
riskLevel?: string | null;
|
||||||
|
riskAssignedAt?: string | null;
|
||||||
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
|
secondDuty?: ClearanceSecondDuty | null;
|
||||||
|
importReleaseGranted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
|
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
|
||||||
@@ -396,6 +478,8 @@ export const IMPORT_MILESTONES = [
|
|||||||
"OFFLOADED",
|
"OFFLOADED",
|
||||||
"T1_CLOSED",
|
"T1_CLOSED",
|
||||||
"RISK_ASSIGNED",
|
"RISK_ASSIGNED",
|
||||||
|
"SECOND_DUTY_ADVISED",
|
||||||
|
"SECOND_DUTY_PAID",
|
||||||
"IMPORT_RELEASE_GRANTED",
|
"IMPORT_RELEASE_GRANTED",
|
||||||
"IMPORT_PROCESS_COMPLETED",
|
"IMPORT_PROCESS_COMPLETED",
|
||||||
"STORAGE_INVOICE_RAISED",
|
"STORAGE_INVOICE_RAISED",
|
||||||
@@ -423,6 +507,7 @@ export const EXPORT_MILESTONES = [
|
|||||||
"DEPARTED_TO_DJIBOUTI",
|
"DEPARTED_TO_DJIBOUTI",
|
||||||
"ARRIVED_AT_DJIBOUTI",
|
"ARRIVED_AT_DJIBOUTI",
|
||||||
"GATEPASS_GRANTED",
|
"GATEPASS_GRANTED",
|
||||||
|
"T1_CLOSED",
|
||||||
"OFFLOADED",
|
"OFFLOADED",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -557,6 +557,21 @@ export interface ClearanceView {
|
|||||||
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
|
||||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||||
t1?: import("./contracts").ClearanceT1State | null;
|
t1?: import("./contracts").ClearanceT1State | null;
|
||||||
|
/** Train link state for the booking (both directions). */
|
||||||
|
train?: import("./contracts").ClearanceTrainState | null;
|
||||||
|
gatepassGranted?: boolean;
|
||||||
|
gatepassAt?: string | null;
|
||||||
|
t1Closed?: boolean;
|
||||||
|
t1ClosedAt?: string | null;
|
||||||
|
offloaded?: boolean;
|
||||||
|
/** GL Djibouti post-offload final invoice (export). */
|
||||||
|
finalInvoice?: import("./contracts").ClearanceFinalInvoiceSummary | null;
|
||||||
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||||
|
riskLevel?: string | null;
|
||||||
|
riskAssignedAt?: string | null;
|
||||||
|
/** Post-arrival additional duty/tax round (import). */
|
||||||
|
secondDuty?: import("./contracts").ClearanceSecondDuty | null;
|
||||||
|
importReleaseGranted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Company an invoice is billed to (minimal projection). */
|
/** Company an invoice is billed to (minimal projection). */
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -217,6 +217,9 @@ importers:
|
|||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/dates':
|
||||||
|
specifier: ^9.3.0
|
||||||
|
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
|
|||||||
Reference in New Issue
Block a user