mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
implement contract cancellation feature and update contract statuses
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice contract suspension (reversible freeze at any post-signature step)
|
||||||
|
* and customer-initiated contract cancellation.
|
||||||
|
*
|
||||||
|
* Only one new column is needed: the status to restore when the suspension is
|
||||||
|
* lifted. The reason and the actor already have a home — contract_review_notes
|
||||||
|
* rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION.
|
||||||
|
*/
|
||||||
|
export class AddContractSuspension3000000000000 implements MigrationInterface {
|
||||||
|
name = 'AddContractSuspension3000000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
}
|
}
|
||||||
@@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Optional,
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
||||||
|
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -56,11 +56,12 @@ export class BookingTransitionService {
|
|||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
private isPhasedCustoms(booking: Booking): boolean {
|
||||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||||
@@ -376,6 +377,8 @@ export class BookingTransitionService {
|
|||||||
} as never);
|
} as never);
|
||||||
const fresh = await this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
this.notifier.completed(fresh);
|
this.notifier.completed(fresh);
|
||||||
|
// A ONE_TIME contract closes on its single shipment being delivered.
|
||||||
|
this.events.emit('booking.completed', { bookingId });
|
||||||
// Customer tracking: close out the tail milestones so a finished shipment
|
// Customer tracking: close out the tail milestones so a finished shipment
|
||||||
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
||||||
// implied by delivery; a storage invoice that was never raised is skipped
|
// implied by delivery; a storage invoice that was never raised is skipped
|
||||||
@@ -491,7 +494,7 @@ export class BookingTransitionService {
|
|||||||
operationReady?: boolean;
|
operationReady?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||||
}
|
}
|
||||||
const { inputCode, outputCode, includesCustoms } =
|
const { inputCode, outputCode, includesCustoms } =
|
||||||
@@ -650,7 +653,7 @@ export class BookingTransitionService {
|
|||||||
status: "DOCUMENTS_UNDER_REVIEW",
|
status: "DOCUMENTS_UNDER_REVIEW",
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
@@ -732,7 +735,7 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
status === 'QUERIED' &&
|
status === 'QUERIED' &&
|
||||||
this.isPhasedGeneralCustoms(booking) &&
|
this.isPhasedCustoms(booking) &&
|
||||||
booking.preClearanceFinalizedAt
|
booking.preClearanceFinalizedAt
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -755,7 +758,7 @@ export class BookingTransitionService {
|
|||||||
"CHANGES_REQUESTED",
|
"CHANGES_REQUESTED",
|
||||||
staffId,
|
staffId,
|
||||||
);
|
);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
@@ -767,7 +770,7 @@ export class BookingTransitionService {
|
|||||||
if (status === "QUERIED") {
|
if (status === "QUERIED") {
|
||||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||||
}
|
}
|
||||||
if (this.isPhasedGeneralCustoms(updated)) {
|
if (this.isPhasedCustoms(updated)) {
|
||||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||||
@@ -817,7 +820,7 @@ export class BookingTransitionService {
|
|||||||
*/
|
*/
|
||||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { SchedulingStatus } from '@edr/types';
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
import {
|
||||||
|
DataSource,
|
||||||
|
DeepPartial,
|
||||||
|
EntityManager,
|
||||||
|
FindOptionsWhere,
|
||||||
|
In,
|
||||||
|
Repository,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -26,6 +34,22 @@ import {
|
|||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||||
|
|
||||||
|
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
|
||||||
|
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||||
|
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspending a contract freezes its bookings, so they drop out of every
|
||||||
|
* scheduling pool. Filtering here (rather than letting the write guard throw)
|
||||||
|
* keeps the batch crons quiet — a frozen contract simply stops being a
|
||||||
|
* candidate until the suspension is lifted.
|
||||||
|
*/
|
||||||
|
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.contracts c
|
||||||
|
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
|
||||||
|
))`;
|
||||||
|
|
||||||
export interface BookingListFilterOptions {
|
export interface BookingListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.repository.findOne({ where: { reference } });
|
return this.repository.findOne({ where: { reference } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspending a contract freezes its bookings too, so the single write path
|
||||||
|
* every booking mutation funnels through is the place to enforce it — one
|
||||||
|
* guard instead of one per transition method.
|
||||||
|
*
|
||||||
|
* The batch/scheduling pools filter suspended contracts out up front
|
||||||
|
* (see {@link excludeSuspendedContract}), so the engine and its crons never
|
||||||
|
* reach a frozen booking and this only ever fires on a user-initiated action.
|
||||||
|
*
|
||||||
|
* ponytail: the seven `manager.getRepository(Booking)` writes inside
|
||||||
|
* train-scheduling transactions bypass this — they only run on bookings the
|
||||||
|
* pool already handed out, which the filter above has excluded. Move them onto
|
||||||
|
* this repository if that ever stops holding.
|
||||||
|
*/
|
||||||
|
private async assertContractNotSuspended(id: string): Promise<void> {
|
||||||
|
const row = await this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.select('contract.status', 'status')
|
||||||
|
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||||
|
.where('booking.id = :id', { id })
|
||||||
|
.getRawOne<{ status: string }>();
|
||||||
|
if (row?.status === 'SUSPENDED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override async update(
|
||||||
|
id: string,
|
||||||
|
data: DeepPartial<Booking>,
|
||||||
|
): Promise<Booking | null> {
|
||||||
|
await this.assertContractNotSuspended(id);
|
||||||
|
return super.update(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
|
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
|
||||||
* Includes soft-deleted bookings so the next number clears references that
|
* Includes soft-deleted bookings so the next number clears references that
|
||||||
@@ -1032,7 +1092,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
'scheduleBooking.booking_id = booking.id',
|
'scheduleBooking.booking_id = booking.id',
|
||||||
)
|
)
|
||||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||||
.andWhere('scheduleBooking.id IS NULL');
|
.andWhere('scheduleBooking.id IS NULL')
|
||||||
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
|
||||||
|
|
||||||
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||||
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||||
@@ -1091,10 +1152,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
@@ -1130,10 +1189,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
{ day },
|
{ day },
|
||||||
)
|
)
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
@@ -1170,10 +1227,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
{ day },
|
{ day },
|
||||||
)
|
)
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
|
|||||||
@@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
|
|||||||
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
|
it('ONE_TIME contract shipments carry the same per-booking set', () => {
|
||||||
|
// Contracts no longer collect clearance documents — every shipment does,
|
||||||
|
// whatever kind of contract it draws on.
|
||||||
const drawdown = clearanceCodesForBooking({
|
const drawdown = clearanceCodesForBooking({
|
||||||
...base,
|
...base,
|
||||||
contractId: 'c1',
|
contractId: 'c1',
|
||||||
contractKind: 'ONE_TIME',
|
contractKind: 'ONE_TIME',
|
||||||
} as unknown as Booking);
|
} as unknown as Booking);
|
||||||
expect(drawdown.inputCode).toBeNull();
|
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||||
expect(drawdown.outputCode).toBeNull();
|
expect(drawdown.outputCode).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The single (admin-configured) document set intercity shipments upload.
|
* The single (admin-configured) document set intercity shipments upload.
|
||||||
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
|
* DOMESTIC has no customs, so one shared set serves every intercity booking —
|
||||||
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
|
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
|
||||||
* reviews either way.
|
|
||||||
*/
|
*/
|
||||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||||
|
|
||||||
@@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
const includesCustoms =
|
const includesCustoms =
|
||||||
Boolean(booking.serviceType?.includesCustoms) ||
|
Boolean(booking.serviceType?.includesCustoms) ||
|
||||||
Boolean(booking.customsClearingEnabled);
|
Boolean(booking.customsClearingEnabled);
|
||||||
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
|
|
||||||
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
|
|
||||||
// direct (contract-less) bookings carry the per-booking set.
|
|
||||||
if (
|
|
||||||
booking.tradeDirection === 'DOMESTIC' &&
|
|
||||||
booking.contractId &&
|
|
||||||
booking.contractKind === 'ONE_TIME'
|
|
||||||
) {
|
|
||||||
return { inputCode: null, outputCode: null, includesCustoms: false };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
inputCode: clearanceSettingCode(
|
inputCode: clearanceSettingCode(
|
||||||
booking.tradeDirection,
|
booking.tradeDirection,
|
||||||
|
|||||||
@@ -113,13 +113,10 @@ export class BookingClearanceService {
|
|||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||||
if (!booking.customsClearingEnabled) {
|
if (!booking.customsClearingEnabled) {
|
||||||
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
||||||
}
|
}
|
||||||
if (booking.contractKind !== 'GENERAL') {
|
|
||||||
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
|
|
||||||
}
|
|
||||||
if (!booking.contractId) {
|
if (!booking.contractId) {
|
||||||
throw new BadRequestException('Booking is not linked to a contract.');
|
throw new BadRequestException('Booking is not linked to a contract.');
|
||||||
}
|
}
|
||||||
@@ -127,7 +124,7 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
private async loadBooking(bookingId: string): Promise<Booking> {
|
private async loadBooking(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
await this.assertPhasedGeneralCustoms(booking);
|
await this.assertPhasedCustoms(booking);
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,11 +349,10 @@ export class BookingClearanceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
|
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
|
||||||
|
isPhasedCustomsBooking(booking: Booking): boolean {
|
||||||
return (
|
return (
|
||||||
Boolean(booking.customsClearingEnabled) &&
|
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
|
||||||
booking.contractKind === 'GENERAL' &&
|
|
||||||
Boolean(booking.contractId)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +716,7 @@ export class BookingClearanceService {
|
|||||||
]);
|
]);
|
||||||
const filtered: Booking[] = [];
|
const filtered: Booking[] = [];
|
||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||||
}
|
}
|
||||||
@@ -733,7 +729,7 @@ export class BookingClearanceService {
|
|||||||
]);
|
]);
|
||||||
const filtered: Booking[] = [];
|
const filtered: Booking[] = [];
|
||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
if (
|
if (
|
||||||
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ export class BookingRequestService {
|
|||||||
'This contract is completed — the full contracted quantity has been booked.',
|
'This contract is completed — the full contracted quantity has been booked.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (contract.status === 'SUSPENDED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
'The contract must be active before requesting a shipment.',
|
'The contract must be active before requesting a shipment.',
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // containerTypesService
|
{} as never, // containerTypesService
|
||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // milestoneService
|
{} as never, // milestoneService
|
||||||
{} as never, // workflowService
|
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('completion on booking delivery', () => {
|
||||||
|
function makeDeliveryService(contract: Partial<Contract>) {
|
||||||
|
const contractsRepository = {
|
||||||
|
findById: jest.fn().mockResolvedValue(contract),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const bookingsRepository = {
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
|
||||||
|
};
|
||||||
|
const service = new ContractBookingService(
|
||||||
|
contractsRepository as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ createdToStaff: jest.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, contractsRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('completes a ONE_TIME contract when its booking is delivered', async () => {
|
||||||
|
const { service, contractsRepository } = makeDeliveryService({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-1',
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
});
|
||||||
|
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
|
||||||
|
|
||||||
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||||
|
|
||||||
|
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CONTRACT_CLOSED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
|
||||||
|
const { service, contractsRepository } = makeDeliveryService({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-1',
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
});
|
||||||
|
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
|
||||||
|
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
|
||||||
|
bulk: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||||
|
|
||||||
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
|
||||||
|
const { service, contractsRepository } = makeDeliveryService({
|
||||||
|
id: 'c-1',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||||
|
|
||||||
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reopens a completed contract when capacity was released', async () => {
|
it('reopens a completed contract when capacity was released', async () => {
|
||||||
const { service, contractsRepository } = makeService();
|
const { service, contractsRepository } = makeService();
|
||||||
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // containerTypesService
|
{} as never, // containerTypesService
|
||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{
|
{
|
||||||
createdToStaff: jest.fn(),
|
createdToStaff: jest.fn(),
|
||||||
|
|||||||
@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
|||||||
|
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import {
|
||||||
|
ContractsRepository,
|
||||||
|
TERMINAL_BOOKING_STATUSES,
|
||||||
|
} from './contracts.repository';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||||
import {
|
import {
|
||||||
CreateBookingContainerLineDto,
|
CreateBookingContainerLineDto,
|
||||||
CreateBookingUnderContractDto,
|
CreateBookingUnderContractDto,
|
||||||
} from './dto/create-booking-under-contract.dto';
|
} from './dto/create-booking-under-contract.dto';
|
||||||
|
|
||||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
|
||||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
|
||||||
|
// same list.
|
||||||
|
|
||||||
/** Bookings that never shipped release their quantity hold on the contract. */
|
/** Bookings that never shipped release their quantity hold on the contract. */
|
||||||
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
@@ -94,7 +98,6 @@ export class ContractBookingService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@@ -187,21 +190,14 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
const freightType = contract.freightType;
|
const freightType = contract.freightType;
|
||||||
|
|
||||||
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
// EVERY contract booking clears per booking now — both contract kinds, both
|
||||||
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
|
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
|
||||||
// operations, and there is NO contract-level clearance cycle to link.
|
// workflow on this booking. Non-customs (Path A) and intercity: the customer
|
||||||
const generalCustoms =
|
// uploads his own document set on the booking and Operations reviews it
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||||
|
// requestOperation; intercity finalize goes straight to the ride-along pool).
|
||||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
// So the booking is always born in the clearance gate, never in the
|
||||||
// uploads his own clearance proof on each booking and Operations reviews it
|
// operations queue, and no contract-level clearance cycle exists to link.
|
||||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
|
||||||
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
|
||||||
// per-booking gate with the intercity document set — ops finalize then puts
|
|
||||||
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
|
||||||
// intercity has no shipment-day request step.
|
|
||||||
const generalSelfClear =
|
|
||||||
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
|
||||||
|
|
||||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||||
// there is no window and no date — staff accept them onto a train at
|
// there is no window and no date — staff accept them onto a train at
|
||||||
@@ -218,24 +214,11 @@ export class ContractBookingService {
|
|||||||
throw new BadRequestException('A binding shipment day is required');
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Booking-window gate (config-driven): an operations booking may only be
|
// No booking-window / export-space gate here any more: every contract
|
||||||
// created while the route's booking window is open — import: the day's window
|
// booking enters the clearance gate first and is scheduled only once the
|
||||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
// documents are approved. Both checks run at that point instead —
|
||||||
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
|
||||||
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
// created with cargo) — against the day the customer actually picks.
|
||||||
// are scheduled later, so they are not gated here.
|
|
||||||
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
|
||||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
|
||||||
originYardId: route?.originYardId ?? null,
|
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
|
||||||
scheduledDate: dto.scheduledDate ?? null,
|
|
||||||
direction: contract.tradeDirection ?? null,
|
|
||||||
});
|
|
||||||
// EXPORT rides whole or not at all (no split concept): reject the booking
|
|
||||||
// up front when no single open train on the day can carry it, telling the
|
|
||||||
// customer how much space is still bookable.
|
|
||||||
await this.assertExportTrainSpace(contract, route, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hard capacity gate: a container line whose total weight exceeds the
|
// Hard capacity gate: a container line whose total weight exceeds the
|
||||||
// container type's max capacity can never be booked — no surcharge path,
|
// container type's max capacity can never be booked — no surcharge path,
|
||||||
@@ -265,10 +248,7 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status:
|
status: 'AWAITING_DOCUMENTS',
|
||||||
generalCustoms || generalSelfClear
|
|
||||||
? 'AWAITING_DOCUMENTS'
|
|
||||||
: 'OPERATION_REQUEST_PENDING',
|
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -375,10 +355,7 @@ export class ContractBookingService {
|
|||||||
// exactly once whether the booking parks for a partner or finalizes inline.
|
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||||
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||||
|
|
||||||
const intendedStatus =
|
const intendedStatus = 'AWAITING_DOCUMENTS';
|
||||||
generalCustoms || generalSelfClear
|
|
||||||
? 'AWAITING_DOCUMENTS'
|
|
||||||
: 'OPERATION_REQUEST_PENDING';
|
|
||||||
if (
|
if (
|
||||||
withContainers &&
|
withContainers &&
|
||||||
freightType === 'CONTAINER' &&
|
freightType === 'CONTAINER' &&
|
||||||
@@ -404,11 +381,7 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.finalizeContractBooking(
|
await this.finalizeContractBooking(booking.id, contract);
|
||||||
booking.id,
|
|
||||||
contract,
|
|
||||||
generalCustoms,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.maybeCompleteContract(contract);
|
await this.maybeCompleteContract(contract);
|
||||||
|
|
||||||
@@ -417,13 +390,20 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate a BARE booking instance under a GENERAL non-customs contract
|
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
|
||||||
* (Path A per-booking self-clearance). One click, zero input: no schedule
|
* or GENERAL, customs or not. One click, zero input: no schedule date, no
|
||||||
* date, no cargo, no window check, no pricing. The instance starts in the
|
* cargo, no window check, no pricing. The instance starts in the clearance
|
||||||
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
|
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
|
||||||
* Operations reviews and finalizes, and only then does the customer complete
|
*
|
||||||
* the booking (cargo + binding day + window check) via
|
* - Path A (self-clearance): the customer initiates, uploads his clearance
|
||||||
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
|
* proof, Operations reviews and finalizes.
|
||||||
|
* - Path B (customs): GL initiates on the customer's behalf, the customer
|
||||||
|
* uploads the GL-input documents on the instance, GL approves them and runs
|
||||||
|
* the phased ET/DJ workflow (pre-booking milestones are seeded here).
|
||||||
|
*
|
||||||
|
* Only after the clearance is finalized is the booking completed (cargo +
|
||||||
|
* binding day + window check) via {@link completeUnderContract} — by the
|
||||||
|
* customer on Path A, by GL on Path B.
|
||||||
*/
|
*/
|
||||||
async initiateUnderContract(
|
async initiateUnderContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -434,13 +414,12 @@ export class ContractBookingService {
|
|||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
const generalSelfClear =
|
// Intercity has no shipment day to defer to, so it is booked directly with
|
||||||
contract.contractKind === 'GENERAL' &&
|
// its cargo (the documents still live on that booking). Everything else —
|
||||||
!contract.customsClearingEnabled &&
|
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
|
||||||
contract.tradeDirection !== 'DOMESTIC';
|
if (contract.tradeDirection === 'DOMESTIC') {
|
||||||
if (!generalSelfClear) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Initiate booking applies only to general import/export contracts without customs clearing.',
|
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,12 +432,27 @@ export class ContractBookingService {
|
|||||||
const isGlActor =
|
const isGlActor =
|
||||||
actorPermissions != null &&
|
actorPermissions != null &&
|
||||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
|
// Customs (Path B): GL initiates on the customer's behalf — assertGate
|
||||||
|
// rejects anyone else. Self-clearance (Path A): the customer initiates.
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||||
|
|
||||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
||||||
|
// slot from the moment it is initiated (it is not a terminal status). The
|
||||||
|
// split chain is the one exception — a paid partial frees the slot and
|
||||||
|
// completion enforces that the next booking takes the whole remainder.
|
||||||
|
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
|
||||||
|
const active = await this.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This one-time contract already has an active booking.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
|
|
||||||
// Bare instance: no cargo, no date, no price. Draws no contract capacity
|
// Bare instance: no cargo, no date, no price. Draws no contract capacity
|
||||||
@@ -503,6 +497,16 @@ export class ContractBookingService {
|
|||||||
} as never),
|
} as never),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
|
||||||
|
// milestones exist from initiation (the post-booking half is seeded when the
|
||||||
|
// booking is completed). Self-clearance has no milestone timeline.
|
||||||
|
if (contract.customsClearingEnabled) {
|
||||||
|
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||||
|
booking.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
this.bookingNotifier.createdToStaff(result ?? booking);
|
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||||
return { booking: result ?? booking, warnings: [] };
|
return { booking: result ?? booking, warnings: [] };
|
||||||
@@ -718,6 +722,15 @@ export class ContractBookingService {
|
|||||||
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
||||||
// the shipment day.
|
// the shipment day.
|
||||||
if (!hasCargo) {
|
if (!hasCargo) {
|
||||||
|
// ONE_TIME split chain: the instance that follows a paid partial must take
|
||||||
|
// the WHOLE outstanding remainder — same rule a booking created with cargo
|
||||||
|
// passes at creation.
|
||||||
|
if (
|
||||||
|
contract.contractKind === 'ONE_TIME' &&
|
||||||
|
(await this.hasSplitBooking(contract.id))
|
||||||
|
) {
|
||||||
|
await this.assertExactRemainder(contract, dto);
|
||||||
|
}
|
||||||
await this.assertWithinQuantityCap(contract, dto);
|
await this.assertWithinQuantityCap(contract, dto);
|
||||||
if (freightType === 'CONTAINER') {
|
if (freightType === 'CONTAINER') {
|
||||||
await this.assertWithinMaxCapacity(contract, dto);
|
await this.assertWithinMaxCapacity(contract, dto);
|
||||||
@@ -817,10 +830,7 @@ export class ContractBookingService {
|
|||||||
// Invoice the now-priced booking and, for a customs instance, seed the
|
// Invoice the now-priced booking and, for a customs instance, seed the
|
||||||
// post-booking milestones (pre-booking ones exist since initiation —
|
// post-booking milestones (pre-booking ones exist since initiation —
|
||||||
// ensure* fills only what is missing). Idempotent, non-blocking.
|
// ensure* fills only what is missing). Idempotent, non-blocking.
|
||||||
const generalCustoms =
|
await this.finalizeContractBooking(booking.id, contract);
|
||||||
contract.contractKind === 'GENERAL' &&
|
|
||||||
Boolean(contract.customsClearingEnabled);
|
|
||||||
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
|
|
||||||
await this.maybeCompleteContract(contract);
|
await this.maybeCompleteContract(contract);
|
||||||
} else if (freightType === 'CONTAINER') {
|
} else if (freightType === 'CONTAINER') {
|
||||||
// Resubmit only re-picks the shipment day — the persisted container
|
// Resubmit only re-picks the shipment day — the persisted container
|
||||||
@@ -886,33 +896,17 @@ export class ContractBookingService {
|
|||||||
private async finalizeContractBooking(
|
private async finalizeContractBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
generalCustoms: boolean,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||||
|
|
||||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
// Customs runs per booking for BOTH contract kinds: seed the full milestone
|
||||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
// timeline on the booking. ensure* skips codes that already exist — an
|
||||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
// initiated instance carries its pre-booking milestones from initiation, and
|
||||||
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
|
// a consolidation pairing replay must not duplicate the timeline. The
|
||||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
|
||||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
// holds no clearance state at all.
|
||||||
if (cycle) {
|
if (contract.customsClearingEnabled) {
|
||||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
|
||||||
}
|
|
||||||
await this.milestoneService.seedPostBookingMilestones(
|
|
||||||
bookingId,
|
|
||||||
contract.tradeDirection,
|
|
||||||
);
|
|
||||||
await this.contractsRepository.update(contract.id, {
|
|
||||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
|
||||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
|
||||||
} as never);
|
|
||||||
} else if (generalCustoms) {
|
|
||||||
// Per-booking clearance: seed the full milestone timeline on the booking.
|
|
||||||
// ensure* skips codes that already exist — an initiated instance carries
|
|
||||||
// its pre-booking milestones from initiation, and a consolidation pairing
|
|
||||||
// replay must not duplicate the timeline.
|
|
||||||
await this.milestoneService.ensureBookingMilestones(
|
await this.milestoneService.ensureBookingMilestones(
|
||||||
bookingId,
|
bookingId,
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
@@ -982,10 +976,7 @@ export class ContractBookingService {
|
|||||||
booking.contractId,
|
booking.contractId,
|
||||||
);
|
);
|
||||||
if (!contract) continue;
|
if (!contract) continue;
|
||||||
const generalCustoms =
|
await this.finalizeContractBooking(id, contract).catch(
|
||||||
contract.contractKind === 'GENERAL' &&
|
|
||||||
Boolean(contract.customsClearingEnabled);
|
|
||||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
|
||||||
(err) =>
|
(err) =>
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||||
@@ -1001,32 +992,27 @@ export class ContractBookingService {
|
|||||||
* allowed to create one for this contract's execution path.
|
* allowed to create one for this contract's execution path.
|
||||||
*/
|
*/
|
||||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||||
|
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||||
|
// of letting the executed-status check below give a misleading reason.
|
||||||
|
if (contract.status === 'SUSPENDED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
// Path B — Global Logistics initiates and completes the booking ON BEHALF
|
||||||
// The customer never books a customs contract himself.
|
// OF the customer. The customer never books a customs contract himself;
|
||||||
|
// he only uploads documents on the instance GL opened for him.
|
||||||
if (!isGlActor) {
|
if (!isGlActor) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.contractKind === 'GENERAL') {
|
// No contract clearance cycle exists on either kind now — clearance runs
|
||||||
// GENERAL customs has NO contract clearance cycle — GL books per accepted
|
// on the booking, so an executed/active contract is the only gate here.
|
||||||
// shipment request while the contract is active; clearance is per booking.
|
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Contract must be active to book a shipment.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return 'GL_ET';
|
|
||||||
}
|
|
||||||
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
|
||||||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
|
||||||
contract.id,
|
|
||||||
contract.tradeDirection,
|
|
||||||
);
|
|
||||||
if (!boundaryOk) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Pre-booking clearance is not complete — booking cannot be created yet.',
|
'Contract must be fully executed before booking a shipment.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 'GL_ET';
|
return 'GL_ET';
|
||||||
@@ -1041,6 +1027,40 @@ export class ContractBookingService {
|
|||||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL worklist: executed ONE_TIME customs contracts with no live shipment
|
||||||
|
* instance yet. Customs contracts are initiated by GL on the customer's
|
||||||
|
* behalf, so without this list a signed contract would sit with nothing on any
|
||||||
|
* queue (clearance lives on the booking, and the booking does not exist yet).
|
||||||
|
* GENERAL customs is excluded — its instances are opened by shipment requests.
|
||||||
|
*/
|
||||||
|
async awaitingShipmentContracts(): Promise<Contract[]> {
|
||||||
|
const { items } = await this.contractsRepository.findAllPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
statuses: ['FULLY_EXECUTED'],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'DESC',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const out: Contract[] = [];
|
||||||
|
for (const contract of items) {
|
||||||
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A split chain frees the slot for the remainder, so those contracts stay
|
||||||
|
// on the list even while the paid partial booking still exists.
|
||||||
|
if (await this.hasSplitBooking(contract.id)) {
|
||||||
|
out.push(contract);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private async countActiveBookings(contractId: string): Promise<number> {
|
private async countActiveBookings(contractId: string): Promise<number> {
|
||||||
return this.dataSource
|
return this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
@@ -1418,6 +1438,53 @@ export class ContractBookingService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ONE_TIME contract carries exactly one shipment: once that booking is
|
||||||
|
* delivered (COMPLETED) the contract is fulfilled and moves to
|
||||||
|
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
|
||||||
|
* blocking any further booking. A split ONE_TIME is the exception: its
|
||||||
|
* remainder chain must be rebooked and delivered first, so the contract stays
|
||||||
|
* open while the split remainder is outstanding.
|
||||||
|
*
|
||||||
|
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
|
||||||
|
* Best-effort: a status hiccup must never fail the booking that completed.
|
||||||
|
*/
|
||||||
|
@OnEvent('booking.completed')
|
||||||
|
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsRepository.findById(payload.bookingId);
|
||||||
|
if (!booking?.contractId) return;
|
||||||
|
const contract = await this.contractsRepository.findById(booking.contractId);
|
||||||
|
if (!contract || contract.contractKind === 'GENERAL') return;
|
||||||
|
// Already closed/expired/cancelled — nothing to do.
|
||||||
|
if (isEffectivelyExpired(contract)) return;
|
||||||
|
|
||||||
|
const outstanding = await this.splitOutstanding(contract);
|
||||||
|
if (outstanding) {
|
||||||
|
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
|
||||||
|
// cap-exhaustion path below.
|
||||||
|
const exhausted =
|
||||||
|
contract.freightType === 'CONTAINER'
|
||||||
|
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
|
||||||
|
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
|
||||||
|
if (!exhausted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contract.id, {
|
||||||
|
status: 'CONTRACT_CLOSED',
|
||||||
|
} as never);
|
||||||
|
this.logger.log(
|
||||||
|
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Could not close contract for completed booking ${payload.bookingId}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete the contract once its quantity cap is fully consumed. Runs after
|
* Complete the contract once its quantity cap is fully consumed. Runs after
|
||||||
* every booking created under a GENERAL contract, and under a ONE_TIME
|
* every booking created under a GENERAL contract, and under a ONE_TIME
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
@@ -1060,46 +1060,6 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
|
||||||
* review of the customer's own clearance documents.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Statuses a non-customs contract passes through around Operations
|
|
||||||
* clearance review — the set a caller may narrow {@link opsQueue} to.
|
|
||||||
*/
|
|
||||||
private static readonly OPS_CLEARANCE_STATUSES = [
|
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
|
||||||
'FULLY_EXECUTED',
|
|
||||||
'CONTRACT_ACTIVE',
|
|
||||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
|
||||||
'CONTRACT_CLOSED',
|
|
||||||
'CANCELLED',
|
|
||||||
];
|
|
||||||
|
|
||||||
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
|
||||||
// Callers may narrow to any subset of the ops-clearance lifecycle (the
|
|
||||||
// hub's status filter sends an explicit list); anything outside the
|
|
||||||
// whitelist is dropped so this endpoint can't become a general contract
|
|
||||||
// browser. No statuses given → the original under-review queue.
|
|
||||||
const requested = (filter.statuses ?? filter.status ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) =>
|
|
||||||
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
|
|
||||||
);
|
|
||||||
return this.contractsRepository.findAllPaginated({
|
|
||||||
page: filter.page ?? 1,
|
|
||||||
pageSize: filter.pageSize ?? 100,
|
|
||||||
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
|
||||||
customsClearingEnabled: false,
|
|
||||||
search: filter.search,
|
|
||||||
sortBy: filter.sortBy,
|
|
||||||
sortOrder: filter.sortOrder,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GL ET history: contracts that completed Path B clearance. */
|
/** GL ET history: contracts that completed Path B clearance. */
|
||||||
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
|
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
@@ -1693,80 +1653,4 @@ export class ContractClearanceService {
|
|||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
|
|
||||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
|
||||||
const base = await this.contractsRepository.findAllPaginated({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 500,
|
|
||||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
|
||||||
customsClearingEnabled: true,
|
|
||||||
contractKind: 'ONE_TIME',
|
|
||||||
sortBy: filter.sortBy,
|
|
||||||
sortOrder: filter.sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered: typeof base.items = [];
|
|
||||||
for (const c of base.items) {
|
|
||||||
const milestones = await this.workflowService.listMilestones(c.id);
|
|
||||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 50;
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const items = filtered.slice(start, start + pageSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: filtered.length,
|
|
||||||
meta: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: filtered.length,
|
|
||||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
|
||||||
hasNextPage: start + pageSize < filtered.length,
|
|
||||||
hasPreviousPage: page > 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
|
|
||||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
|
||||||
const base = await this.contractsRepository.findAllPaginated({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 500,
|
|
||||||
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
|
|
||||||
customsClearingEnabled: true,
|
|
||||||
contractKind: 'ONE_TIME',
|
|
||||||
sortBy: filter.sortBy,
|
|
||||||
sortOrder: filter.sortOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered: typeof base.items = [];
|
|
||||||
for (const c of base.items) {
|
|
||||||
const cycle = await this.contractsRepository.currentCycle(c.id);
|
|
||||||
const milestones = await this.workflowService.listMilestones(c.id);
|
|
||||||
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
|
|
||||||
filtered.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 50;
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const items = filtered.slice(start, start + pageSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: filtered.length,
|
|
||||||
meta: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: filtered.length,
|
|
||||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
|
||||||
hasNextPage: start + pageSize < filtered.length,
|
|
||||||
hasPreviousPage: page > 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractsService } from './contracts.service';
|
||||||
|
import type { CreateContractDto } from './dto/create-contract.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duplicate guard blocks a new request only when EVERY commercial
|
||||||
|
* dimension matches a live contract — service type, operation type, contract
|
||||||
|
* kind, cargo scope and route. Any one differing must let the request through.
|
||||||
|
*/
|
||||||
|
describe('ContractsService duplicate guard', () => {
|
||||||
|
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
|
||||||
|
|
||||||
|
const existing = {
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00001',
|
||||||
|
status: 'PENDING_APPROVAL',
|
||||||
|
contractValidUntil: null,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
routes: [LANE],
|
||||||
|
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dto = (overrides: Partial<CreateContractDto> = {}) =>
|
||||||
|
({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
routes: [LANE],
|
||||||
|
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
|
||||||
|
...overrides,
|
||||||
|
}) as CreateContractDto;
|
||||||
|
|
||||||
|
const guard = (input: CreateContractDto) => {
|
||||||
|
const service = new ContractsService(
|
||||||
|
{} as never,
|
||||||
|
{ findDuplicateCandidates: async () => [existing] } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
service as unknown as {
|
||||||
|
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
|
||||||
|
}
|
||||||
|
).assertNoDuplicateContract('company-1', input);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('blocks an identical request', async () => {
|
||||||
|
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['operation type', { tradeDirection: 'EXPORT' }],
|
||||||
|
['contract kind', { contractKind: 'GENERAL' }],
|
||||||
|
['freight type', { freightType: 'BULK' }],
|
||||||
|
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
|
||||||
|
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
|
||||||
|
])('allows a request with a different %s', async (_label, overrides) => {
|
||||||
|
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores quantity caps when comparing cargo scope', async () => {
|
||||||
|
await expect(
|
||||||
|
guard(
|
||||||
|
dto({
|
||||||
|
cargoScope: [
|
||||||
|
{ containerSize: '20ft', quantityCap: 10 },
|
||||||
|
{ containerSize: '40ft', quantityCap: 5 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,6 +143,33 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract rejected', msg);
|
this.inApp(c, 'Contract rejected', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backoffice froze the contract — every action on it is blocked until lifted. */
|
||||||
|
suspended(c: Contract, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
|
||||||
|
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
|
||||||
|
void this.notifyContact(c, msg, 'SUSPENDED');
|
||||||
|
this.inApp(c, 'Contract suspended', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backoffice lifted the suspension — the contract resumes where it left off. */
|
||||||
|
suspensionLifted(c: Contract, note?: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`The suspension on your contract ${c.reference} has been lifted. ` +
|
||||||
|
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
|
||||||
|
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
|
||||||
|
this.inApp(c, 'Contract suspension lifted', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Contract cancelled by customer',
|
||||||
|
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A later approver sent the contract back to an earlier stage of the chain.
|
* A later approver sent the contract back to an earlier stage of the chain.
|
||||||
* Staff-only: the customer is not involved in an internal send-back — their
|
* Staff-only: the customer is not involved in an internal send-back — their
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspension is only worth having if it is reversible and if it actually
|
||||||
|
* freezes things, and the customer's own cancel is only safe while no shipment
|
||||||
|
* is running. Those three rules are the whole feature — everything else is
|
||||||
|
* plumbing.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: {
|
||||||
|
update: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
countActiveBookings: jest.Mock;
|
||||||
|
};
|
||||||
|
let notifier: {
|
||||||
|
suspended: jest.Mock;
|
||||||
|
suspensionLifted: jest.Mock;
|
||||||
|
cancelledByCustomer: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
/** A staff user holding the suspend key — authorization is tested elsewhere. */
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
// Mirror the real repository: the update patches the row the next
|
||||||
|
// findById returns, so resume() reads what suspend() wrote.
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
notifier = {
|
||||||
|
suspended: jest.fn(),
|
||||||
|
suspensionLifted: jest.fn(),
|
||||||
|
cancelledByCustomer: jest.fn(),
|
||||||
|
};
|
||||||
|
// These three transitions touch only the repository, the read-back service
|
||||||
|
// and the notifier — the other 14 constructor deps stay unused, so the
|
||||||
|
// instance is built bare and only what is exercised is injected.
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes at the current step and remembers where to come back to', async () => {
|
||||||
|
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
|
||||||
|
|
||||||
|
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'SUSPENDED',
|
||||||
|
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
|
||||||
|
});
|
||||||
|
expect(notifier.suspended).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the pre-suspension status when the suspension is lifted', async () => {
|
||||||
|
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
|
||||||
|
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
await service.resume('c-1', undefined, 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
|
||||||
|
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to suspend a contract the customer has not signed yet', async () => {
|
||||||
|
current = contract({ status: 'PENDING_APPROVAL' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.suspend('c-1', 'too early', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/PENDING_APPROVAL/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the customer cancel a contract with no live shipment', async () => {
|
||||||
|
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Changed supplier',
|
||||||
|
'CANCELLATION',
|
||||||
|
'user-1',
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the customer cancel while a shipment is still running', async () => {
|
||||||
|
repo.countActiveBookings.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/2 active shipments/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
|
||||||
|
current = contract({ status: 'SUSPENDED' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/suspended/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
FREIGHT_PERMS,
|
FREIGHT_PERMS,
|
||||||
forFreightType,
|
forFreightType,
|
||||||
} from '../../seed/freight-permissions.registry';
|
} from '../../seed/freight-permissions.registry';
|
||||||
|
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
@@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
|
|||||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
ContractDocumentArticle,
|
ContractDocumentArticle,
|
||||||
@@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
|
|||||||
.join(' and ');
|
.join(' and ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the backoffice may freeze a contract: every step from the customer's
|
||||||
|
* signature onward, up to (but not including) the terminal states. Suspending
|
||||||
|
* an unsigned contract is meaningless — staff reject or request changes there.
|
||||||
|
*/
|
||||||
|
export const SUSPENDABLE_CONTRACT_STATUSES = [
|
||||||
|
'SIGNED_CUSTOMER',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'CONTRACT_ACTIVE',
|
||||||
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** Status-machine guard mirroring booking-status.util. */
|
/** Status-machine guard mirroring booking-status.util. */
|
||||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||||
if (!allowed.includes(contract.status)) {
|
if (!allowed.includes(contract.status)) {
|
||||||
@@ -154,7 +168,6 @@ export class ContractTransitionService {
|
|||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly signaturesService: SignaturesService,
|
private readonly signaturesService: SignaturesService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
|
||||||
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
||||||
private readonly renderer: ContractRendererService,
|
private readonly renderer: ContractRendererService,
|
||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
@@ -1262,43 +1275,15 @@ export class ContractTransitionService {
|
|||||||
lockedAt: now,
|
lockedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
|
||||||
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
// intercity. A signed contract carries no clearance cycle and collects no
|
||||||
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
// documents: the shipment instance created after signature does. Customs
|
||||||
const clearanceCode = contractClearanceSettingCode(
|
// (Path B): GL initiates the booking, the customer uploads on it, GL
|
||||||
contract.tradeDirection,
|
// reviews and completes it. Self-clearance (Path A) and intercity: the
|
||||||
contract.freightType,
|
// customer initiates/books and Operations reviews the booking documents.
|
||||||
contract.customsClearingEnabled ?? false,
|
updates.status =
|
||||||
);
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
|
||||||
// both paths. Customs (Path B): the customer files shipment requests, GL
|
|
||||||
// books each one and the booking carries its own clearance. Self-clearance
|
|
||||||
// (Path A): the customer books, then uploads the clearance docs on that
|
|
||||||
// booking for Operations to review. Only ONE_TIME contracts keep the
|
|
||||||
// contract-level cycle below.
|
|
||||||
const isGeneral = contract.contractKind === 'GENERAL';
|
|
||||||
|
|
||||||
if (clearanceCode && !isGeneral) {
|
|
||||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
|
||||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
|
||||||
// distinction is enforced at the review/finalize endpoints, not here.
|
|
||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
|
||||||
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
|
||||||
// the booking invoice together with the freight, so the document step
|
|
||||||
// opens immediately.
|
|
||||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
|
||||||
} else {
|
|
||||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
|
||||||
// (which clears per booking). Ready for shipment requests / direct booking.
|
|
||||||
updates.status =
|
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
await this.regenerateContractPdf(contractId, contract.reference);
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
@@ -1308,6 +1293,123 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||||
|
/**
|
||||||
|
* Backoffice freeze, available at every step from the customer signature
|
||||||
|
* onward. The pre-suspension status is stashed so {@link resume} can put the
|
||||||
|
* contract back exactly where it was — a suspension you cannot lift is just a
|
||||||
|
* cancellation under another name.
|
||||||
|
*
|
||||||
|
* While SUSPENDED nothing moves: no new bookings or shipment requests
|
||||||
|
* (ContractBookingService / BookingRequestService), and no writes to the
|
||||||
|
* contract's existing bookings (BookingsRepository.update).
|
||||||
|
*/
|
||||||
|
async suspend(
|
||||||
|
contractId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'SUSPENSION',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'SUSPENDED',
|
||||||
|
statusBeforeSuspension: contract.status,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.suspended(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lift a suspension — the contract returns to the status it was frozen at. */
|
||||||
|
async resume(
|
||||||
|
contractId: string,
|
||||||
|
note: string | undefined,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
assertContractStatus(contract, ['SUSPENDED']);
|
||||||
|
|
||||||
|
// Legacy safety net: a row suspended before the column existed has nothing
|
||||||
|
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
|
||||||
|
// contract kinds, so it is the only sane default.
|
||||||
|
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
|
||||||
|
|
||||||
|
if (note?.trim()) {
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
note.trim(),
|
||||||
|
'SUSPENSION_LIFTED',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: restored,
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.suspensionLifted(updated, note ?? null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer cancels their own contract so they can request a fresh one for the
|
||||||
|
* same lane — the duplicate-contract guard treats CANCELLED as released.
|
||||||
|
* Blocked while any booking on the contract is still live: cancelling a
|
||||||
|
* contract with cargo in motion would strand it.
|
||||||
|
*/
|
||||||
|
async cancelByCustomer(
|
||||||
|
contractId: string,
|
||||||
|
reason: string | undefined,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.status === 'SUSPENDED') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This contract is suspended by EDR — contact us to lift the suspension first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||||
|
'Cancel or complete them before cancelling the contract.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = reason?.trim() || 'Cancelled by the customer.';
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
body,
|
||||||
|
'CANCELLATION',
|
||||||
|
userId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.cancelledByCustomer(updated, body);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
|||||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||||
import {
|
import {
|
||||||
|
CancelContractDto,
|
||||||
RejectContractDto,
|
RejectContractDto,
|
||||||
RejectStepDto,
|
RejectStepDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
|
ResumeContractDto,
|
||||||
|
SuspendContractDto,
|
||||||
} from './dto/approve-step.dto';
|
} from './dto/approve-step.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
@@ -453,6 +456,65 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/suspend')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
|
||||||
|
})
|
||||||
|
suspend(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SuspendContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.suspend(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/resume')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
|
||||||
|
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
|
||||||
|
resume(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ResumeContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.resume(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Customer cancels their own contract (blocked while a booking is live)',
|
||||||
|
})
|
||||||
|
async cancel(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CancelContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
|
||||||
|
// through, everyone else must own the contract's company.
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
|
return this.transitionService.cancelByCustomer(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
@@ -924,31 +986,18 @@ export class ContractsController {
|
|||||||
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('clearance/et-queue')
|
@Get('awaiting-shipment')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
|
@ApiOperation({
|
||||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
summary:
|
||||||
return this.clearanceService.etQueue(filter);
|
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
|
||||||
}
|
})
|
||||||
|
awaitingShipmentContracts() {
|
||||||
@Get('clearance/dj-queue')
|
return this.contractBookingService.awaitingShipmentContracts();
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
|
|
||||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
|
||||||
return this.clearanceService.djQueue(filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
|
|
||||||
})
|
|
||||||
opsClearanceQueue(@Query() filter: FilterContractDto) {
|
|
||||||
return this.clearanceService.opsQueue(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/clearance/ops-review')
|
@Post(':id/clearance/ops-review')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -1017,7 +1066,7 @@ export class ContractsController {
|
|||||||
@Post(':id/bookings/initiate')
|
@Post(':id/bookings/initiate')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
|
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). Customs contracts are initiated by GL Ethiopia.',
|
||||||
})
|
})
|
||||||
initiateBooking(
|
initiateBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -1335,7 +1384,7 @@ export class ContractsController {
|
|||||||
) {
|
) {
|
||||||
const file = (files ?? [])[0];
|
const file = (files ?? [])[0];
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
|
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
|
||||||
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
||||||
}
|
}
|
||||||
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||||
@@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-
|
|||||||
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking statuses that release whatever the booking was holding — contract
|
||||||
|
* capacity, the one-time active slot, the cancel gate. Everything else counts
|
||||||
|
* as a live booking.
|
||||||
|
*/
|
||||||
|
export const TERMINAL_BOOKING_STATUSES = [
|
||||||
|
'EXPIRED',
|
||||||
|
'CANCELLED',
|
||||||
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
|
];
|
||||||
|
|
||||||
export interface ContractListFilterOptions {
|
export interface ContractListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-terminal contracts for the same company + service type, with routes
|
* Non-terminal contracts for the same company + service type, with routes and
|
||||||
* loaded — candidates for the duplicate-contract check on create(). Terminal
|
* cargo scope loaded — candidates for the duplicate-contract check on
|
||||||
|
* create() (which also compares operation type, kind and scope). Terminal
|
||||||
* filtering happens in JS via isEffectivelyExpired (also covers the
|
* filtering happens in JS via isEffectivelyExpired (also covers the
|
||||||
* date-passed-but-not-yet-cron-flipped case).
|
* date-passed-but-not-yet-cron-flipped case).
|
||||||
*/
|
*/
|
||||||
@@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return this.repository
|
return this.repository
|
||||||
.createQueryBuilder('contract')
|
.createQueryBuilder('contract')
|
||||||
.leftJoinAndSelect('contract.routes', 'routes')
|
.leftJoinAndSelect('contract.routes', 'routes')
|
||||||
|
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||||
.where('contract.deleted_at IS NULL')
|
.where('contract.deleted_at IS NULL')
|
||||||
.andWhere('contract.company_id = :companyId', { companyId })
|
.andWhere('contract.company_id = :companyId', { companyId })
|
||||||
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
|
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
|
||||||
@@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
|
|
||||||
// ── Review notes ──────────────────────────────────────────────────────────────
|
// ── Review notes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookings on the contract that have not reached a terminal state. Gates the
|
||||||
|
* customer's own contract cancellation (a contract carrying live cargo may
|
||||||
|
* not be cancelled) and is surfaced on the detail response so the portal can
|
||||||
|
* disable the button instead of failing the call.
|
||||||
|
*/
|
||||||
|
async countActiveBookings(contractId: string): Promise<number> {
|
||||||
|
return this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.contract_id = :contractId', { contractId })
|
||||||
|
.andWhere('b.status NOT IN (:...terminal)', {
|
||||||
|
terminal: TERMINAL_BOOKING_STATUSES,
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
async createReviewNote(
|
async createReviewNote(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
|||||||
@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order-independent identity of a cargo scope — two contracts cover the same
|
||||||
|
* cargo only when they list the same container sizes / commodities. Quantity
|
||||||
|
* caps are deliberately ignored: they size a GENERAL contract, they don't make
|
||||||
|
* it a different scope.
|
||||||
|
*/
|
||||||
|
function cargoScopeKey(
|
||||||
|
scope?: Array<
|
||||||
|
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
|
||||||
|
> | null,
|
||||||
|
): string {
|
||||||
|
if (!scope?.length) return '';
|
||||||
|
return scope
|
||||||
|
.map((row) =>
|
||||||
|
[
|
||||||
|
row.containerSize?.trim().toLowerCase() ?? '',
|
||||||
|
row.cargoTypeId ?? '',
|
||||||
|
row.cargoFreeText?.trim().toLowerCase() ?? '',
|
||||||
|
].join('|'),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
@@ -190,25 +214,34 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same customer + same service type + an overlapping route already has a
|
* A live contract only blocks a new request when EVERY commercial dimension
|
||||||
* non-expired contract → block. A route "overlaps" if any origin/destination
|
* of the wizard matches it: service type, operation type (trade direction),
|
||||||
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
|
* contract kind, cargo scope and route. Change any one of them — a different
|
||||||
* carry a single route in practice, and still correct if that changes.
|
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
|
||||||
|
* customer may request another contract.
|
||||||
|
*
|
||||||
|
* A route "overlaps" if any origin/destination pair matches; cargo scope
|
||||||
|
* matches only when the two scope sets are identical (same freight type and
|
||||||
|
* the same container sizes / commodities).
|
||||||
*/
|
*/
|
||||||
private async assertNoDuplicateContract(
|
private async assertNoDuplicateContract(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
serviceTypeId: string,
|
dto: CreateContractDto,
|
||||||
routes: CreateContractDto['routes'],
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
||||||
companyId,
|
companyId,
|
||||||
serviceTypeId,
|
dto.serviceTypeId,
|
||||||
);
|
);
|
||||||
|
const incomingScope = cargoScopeKey(dto.cargoScope);
|
||||||
const duplicate = candidates.find(
|
const duplicate = candidates.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
!isEffectivelyExpired(c) &&
|
!isEffectivelyExpired(c) &&
|
||||||
|
c.tradeDirection === dto.tradeDirection &&
|
||||||
|
c.contractKind === dto.contractKind &&
|
||||||
|
c.freightType === dto.freightType &&
|
||||||
|
cargoScopeKey(c.cargoScope) === incomingScope &&
|
||||||
(c.routes ?? []).some((existingRoute) =>
|
(c.routes ?? []).some((existingRoute) =>
|
||||||
routes.some(
|
dto.routes.some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.originYardId === existingRoute.originYardId &&
|
r.originYardId === existingRoute.originYardId &&
|
||||||
r.destinationYardId === existingRoute.destinationYardId,
|
r.destinationYardId === existingRoute.destinationYardId,
|
||||||
@@ -220,7 +253,7 @@ export class ContractsService {
|
|||||||
? duplicate.contractValidUntil.toISOString().slice(0, 10)
|
? duplicate.contractValidUntil.toISOString().slice(0, 10)
|
||||||
: 'its approval completes';
|
: 'its approval completes';
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
|
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,7 +290,7 @@ export class ContractsService {
|
|||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
|
await this.assertNoDuplicateContract(companyId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||||
@@ -813,6 +846,24 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the contract is frozen — shown to staff and customer alike.
|
||||||
|
if (contract.status === 'SUSPENDED') {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'SUSPENSION',
|
||||||
|
);
|
||||||
|
contract.latestSuspensionNote = note?.body ?? null;
|
||||||
|
} catch {
|
||||||
|
contract.latestSuspensionNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||||||
|
// click it and read a 400. The API re-checks on cancel regardless.
|
||||||
|
contract.activeBookingCount =
|
||||||
|
await this.contractsRepository.countActiveBookings(contract.id);
|
||||||
|
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,17 @@ export class CancelContractDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SuspendContractDto {
|
||||||
|
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResumeContractDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
* correct it. One row per round — the advice/dispute loop can repeat.
|
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||||
*/
|
*/
|
||||||
'DUTY_DISPUTE',
|
'DUTY_DISPUTE',
|
||||||
|
/** Backoffice froze the contract; body is the reason shown to the customer. */
|
||||||
|
'SUSPENSION',
|
||||||
|
/** Backoffice lifted a suspension; body is the optional lift note. */
|
||||||
|
'SUSPENSION_LIFTED',
|
||||||
|
/** Customer cancelled their own contract; body is their reason. */
|
||||||
|
'CANCELLATION',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
|
|||||||
'CLEARANCE_UNDER_REVIEW',
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
// Reversible backoffice freeze — see statusBeforeSuspension.
|
||||||
|
'SUSPENDED',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
'EXPIRED',
|
'EXPIRED',
|
||||||
'REJECTED',
|
'REJECTED',
|
||||||
@@ -217,6 +219,14 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status the contract held when the backoffice suspended it, restored when
|
||||||
|
* the suspension is lifted. Null unless the contract is (or once was)
|
||||||
|
* SUSPENDED. A suspension without this would just be a cancellation.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
||||||
|
statusBeforeSuspension?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||||
clearanceStatus!: string;
|
clearanceStatus!: string;
|
||||||
|
|
||||||
@@ -343,4 +353,18 @@ export class Contract extends BaseEntity {
|
|||||||
* contract_review_notes, not a column here.
|
* contract_review_notes, not a column here.
|
||||||
*/
|
*/
|
||||||
latestSendBackNote?: string | null;
|
latestSendBackNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent SUSPENSION review note, attached by
|
||||||
|
* ContractsService.findById while the contract is SUSPENDED so both sides see
|
||||||
|
* why it was frozen. Lives in contract_review_notes, not a column here.
|
||||||
|
*/
|
||||||
|
latestSuspensionNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of this contract's non-terminal bookings, attached by
|
||||||
|
* ContractsService.findById. The portal disables customer cancellation while
|
||||||
|
* it is > 0 (the API enforces the same). Not a column.
|
||||||
|
*/
|
||||||
|
activeBookingCount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||||
|
|
||||||
describe('BookingBatchService — PAID reconcile', () => {
|
describe('BookingBatchService — PAID reconcile', () => {
|
||||||
const scheduleId = 'schedule-1';
|
const scheduleId = 'schedule-1';
|
||||||
@@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
previewPaidBookingWagonShortage: jest.Mock;
|
previewPaidBookingWagonShortage: jest.Mock;
|
||||||
getBookableSchedules: jest.Mock;
|
getBookableSchedules: jest.Mock;
|
||||||
getWindowConfig: jest.Mock;
|
getWindowConfig: jest.Mock;
|
||||||
|
wagonStockForSchedule: jest.Mock;
|
||||||
};
|
};
|
||||||
let dataSource: {
|
let dataSource: {
|
||||||
getRepository: jest.Mock;
|
getRepository: jest.Mock;
|
||||||
transaction: jest.Mock;
|
transaction: jest.Mock;
|
||||||
|
query: jest.Mock;
|
||||||
};
|
};
|
||||||
let notifier: {
|
let notifier: {
|
||||||
payNow: jest.Mock;
|
payNow: jest.Mock;
|
||||||
@@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
// No shortage by default — paid bookings link as before.
|
// No shortage by default — paid bookings link as before.
|
||||||
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
|
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
|
||||||
|
// No physical stock configured → the wagon-type gate stands down and these
|
||||||
|
// specs keep testing the abstract capacity budget on its own.
|
||||||
|
wagonStockForSchedule: jest.fn().mockResolvedValue({
|
||||||
|
mode: 'YARD',
|
||||||
|
remainingByTypeId: new Map<string, number>(),
|
||||||
|
codesByTypeId: new Map<string, string>(),
|
||||||
|
}),
|
||||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||||
getWindowConfig: jest.fn().mockResolvedValue({
|
getWindowConfig: jest.fn().mockResolvedValue({
|
||||||
importWindowLeadDays: 3,
|
importWindowLeadDays: 3,
|
||||||
@@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
};
|
};
|
||||||
await fn(manager);
|
await fn(manager);
|
||||||
}),
|
}),
|
||||||
|
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
|
||||||
|
// Empty = unresolvable, so the physical-stock gate stands down and these
|
||||||
|
// specs keep exercising the abstract capacity budget alone.
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
notifier = {
|
notifier = {
|
||||||
@@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
|||||||
return genericRepo;
|
return genericRepo;
|
||||||
}),
|
}),
|
||||||
transaction: jest.fn(),
|
transaction: jest.fn(),
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
const service = new BookingBatchService(
|
const service = new BookingBatchService(
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
@@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reported failure: a train advertising 20 free wagons where only 16 are of
|
||||||
|
* the type the booking can ride. Selecting all 20 took the customer's money for
|
||||||
|
* space that never existed and then stalled at allocation on wagon 17.
|
||||||
|
*/
|
||||||
|
describe('BookingBatchService — physical wagon-type gate', () => {
|
||||||
|
const NW5 = 'wagon-type-nw5';
|
||||||
|
const PW2 = 'wagon-type-pw2';
|
||||||
|
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
|
||||||
|
|
||||||
|
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
|
||||||
|
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
|
||||||
|
|
||||||
|
const internals = (svc: BookingBatchService) =>
|
||||||
|
svc as unknown as {
|
||||||
|
hasWagonStock: (
|
||||||
|
stock: WagonStockLedger,
|
||||||
|
ids: string[],
|
||||||
|
needed: number,
|
||||||
|
leg: { fromEdge: number; toEdge: number },
|
||||||
|
) => boolean;
|
||||||
|
maybeOfferPartial: (
|
||||||
|
booking: Booking,
|
||||||
|
isPair: boolean,
|
||||||
|
candidates: unknown[],
|
||||||
|
need: { wagons: number; weightTons: number; lengthMeters: number },
|
||||||
|
ids: string[],
|
||||||
|
) => Promise<boolean>;
|
||||||
|
tryPartialOffer: unknown;
|
||||||
|
isSplitEligible: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = () =>
|
||||||
|
new BookingBatchService(
|
||||||
|
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
undefined,
|
||||||
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
|
||||||
|
const svc = internals(service());
|
||||||
|
const stock = mixedStock();
|
||||||
|
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
|
||||||
|
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
|
||||||
|
// A booking that may ride either type sees all 20.
|
||||||
|
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands down when the booking has no allowed wagon type configured', () => {
|
||||||
|
// Unresolvable configuration must not strand every booking that uses it —
|
||||||
|
// the abstract capacity budget still governs.
|
||||||
|
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
|
||||||
|
const svc = service();
|
||||||
|
const inner = internals(svc);
|
||||||
|
// Isolate the sizing decision: eligibility and offer creation are covered
|
||||||
|
// elsewhere, what matters here is the room handed to tryPartialOffer.
|
||||||
|
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
|
||||||
|
const tryPartial = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
|
||||||
|
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
|
||||||
|
|
||||||
|
const stock = mixedStock();
|
||||||
|
const candidate = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
// 20 abstract slots free, weight and length wide open.
|
||||||
|
budget: {
|
||||||
|
legOf: () => WHOLE_LEG,
|
||||||
|
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
|
||||||
|
subtract: jest.fn(),
|
||||||
|
},
|
||||||
|
armed: false,
|
||||||
|
stock,
|
||||||
|
};
|
||||||
|
|
||||||
|
const offered = await inner.maybeOfferPartial(
|
||||||
|
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
|
||||||
|
false,
|
||||||
|
[candidate],
|
||||||
|
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
|
||||||
|
[NW5],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offered).toBe(true);
|
||||||
|
// 16, not the 20 free slots — the customer is billed for what can be loaded.
|
||||||
|
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
|
||||||
|
// Those 16 are now held, so the next booking in the pass cannot re-take them.
|
||||||
|
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import {
|
|||||||
OverageTolerance,
|
OverageTolerance,
|
||||||
stopYardsFor,
|
stopYardsFor,
|
||||||
} from './corridor-capacity.util';
|
} from './corridor-capacity.util';
|
||||||
|
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||||
|
|
||||||
export type { Capacity } from './corridor-capacity.util';
|
export type { Capacity } from './corridor-capacity.util';
|
||||||
|
|
||||||
@@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
|
const stock = await this.stockLedgerFor(schedule, budget);
|
||||||
|
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||||
if (budget.isExhausted(minPerWagon)) {
|
if (budget.isExhausted(minPerWagon)) {
|
||||||
await this.setWindow(scheduleId, "FULL");
|
await this.setWindow(scheduleId, "FULL");
|
||||||
@@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// Consolidated partners always share one corridor, so the primary's leg
|
// Consolidated partners always share one corridor, so the primary's leg
|
||||||
// stands for the pair.
|
// stands for the pair.
|
||||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
|
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||||
|
// Abstract room AND real wagons of a type this booking can ride — see
|
||||||
|
// fillRouteDayInternal for why both gates are needed.
|
||||||
|
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||||||
|
|
||||||
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
|
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
|
||||||
|
`stocked=${stocked}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!budget.fits(need, leg)) {
|
if (!budget.fits(need, leg) || !stocked) {
|
||||||
if (isGov) {
|
if (isGov) {
|
||||||
const freed = await this.preemptForGovernment(
|
const freed = await this.preemptForGovernment(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||||
// that fits in the remaining room (top-up path splits the boundary
|
// that fits in the remaining room (top-up path splits the boundary
|
||||||
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||||
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
const cand: {
|
||||||
id: scheduleId,
|
id: string;
|
||||||
budget,
|
budget: CorridorBudget;
|
||||||
armed,
|
armed: boolean;
|
||||||
};
|
stock: WagonStockLedger;
|
||||||
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
} = { id: scheduleId, budget, armed, stock };
|
||||||
|
if (
|
||||||
|
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
|
||||||
|
) {
|
||||||
armed = cand.armed;
|
armed = cand.armed;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
commercialReserved += 1;
|
commercialReserved += 1;
|
||||||
}
|
}
|
||||||
budget.subtract(need, leg);
|
budget.subtract(need, leg);
|
||||||
|
// Hold the physical wagons too — the next unit must not re-count them.
|
||||||
|
stock.consume(wagonTypeIds, need.wagons, leg);
|
||||||
reservedThisPass += 1;
|
reservedThisPass += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||||||
|
|
||||||
// Live per-schedule corridor budget + arm/changed flags, in departure order.
|
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
|
||||||
|
// flags, in departure order.
|
||||||
const trains: Array<{
|
const trains: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
budget: CorridorBudget;
|
budget: CorridorBudget;
|
||||||
|
stock: WagonStockLedger;
|
||||||
armed: boolean;
|
armed: boolean;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
@@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
trains.push({ id, budget, armed: false, changed: false });
|
const stock = await this.stockLedgerFor(schedule, budget);
|
||||||
|
trains.push({ id, budget, stock, armed: false, changed: false });
|
||||||
}
|
}
|
||||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||||
|
|
||||||
@@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||||
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
|
// Consolidated pairs share one wagon set; the primary's types stand for both.
|
||||||
|
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||||
|
|
||||||
// First train (earliest departure) whose corridor carries this booking's
|
// First train (earliest departure) whose corridor carries this booking's
|
||||||
// leg and still fits it as-is.
|
// leg, still fits it as-is AND physically holds enough wagons of a type the
|
||||||
|
// booking can ride. Both gates matter: abstract room without the right
|
||||||
|
// wagon type is space the allocator can never turn into a loaded consist.
|
||||||
let target = trains.find((t) => {
|
let target = trains.find((t) => {
|
||||||
const leg = legOn(t);
|
const leg = legOn(t);
|
||||||
return leg != null && t.budget.fits(need, leg);
|
return (
|
||||||
|
leg != null &&
|
||||||
|
t.budget.fits(need, leg) &&
|
||||||
|
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||||
@@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// already consumed most of the room). Consolidated pairs / government /
|
// already consumed most of the room). Consolidated pairs / government /
|
||||||
// non-import never split — isSplitEligible guards that. Passing the live
|
// non-import never split — isSplitEligible guards that. Passing the live
|
||||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
const offered = await this.maybeOfferPartial(
|
||||||
|
booking,
|
||||||
|
isPair,
|
||||||
|
trains,
|
||||||
|
need,
|
||||||
|
wagonTypeIds,
|
||||||
|
);
|
||||||
if (offered) {
|
if (offered) {
|
||||||
// A partial offer opens a real commercial pay window, same as reserve().
|
// A partial offer opens a real commercial pay window, same as reserve().
|
||||||
commercialReserved += 1;
|
commercialReserved += 1;
|
||||||
@@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
commercialReserved += 1;
|
commercialReserved += 1;
|
||||||
}
|
}
|
||||||
target.budget.subtract(need, legOn(target)!);
|
target.budget.subtract(need, legOn(target)!);
|
||||||
|
// Hold the physical wagons too, so the next unit in this pass sees them
|
||||||
|
// gone — otherwise two bookings both "fit" the same 16 NW5.
|
||||||
|
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
|
||||||
target.changed = true;
|
target.changed = true;
|
||||||
reservedThisPass += 1;
|
reservedThisPass += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private async maybeOfferPartial(
|
private async maybeOfferPartial(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
isPair: boolean,
|
isPair: boolean,
|
||||||
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
candidates: Array<{
|
||||||
|
id: string;
|
||||||
|
budget: CorridorBudget;
|
||||||
|
armed: boolean;
|
||||||
|
stock?: WagonStockLedger;
|
||||||
|
}>,
|
||||||
need: Capacity,
|
need: Capacity,
|
||||||
|
wagonTypeIds: string[] = [],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||||
const target = candidates
|
const target = candidates
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
|
if (!leg) return null;
|
||||||
|
const room = c.budget.remainingFor(leg);
|
||||||
|
// The offer may never exceed the wagons that physically exist in a type
|
||||||
|
// this booking can ride. This is what turns "20 free wagons, only 16 of
|
||||||
|
// them NW5" into an offer for 16 — the customer pays for 16 and the
|
||||||
|
// other 4 leave as the usual remainder booking, instead of paying for
|
||||||
|
// 20 and stalling at allocation on wagon 17.
|
||||||
|
const physical = wagonTypeIds.length
|
||||||
|
? c.stock?.availableFor(wagonTypeIds, leg)
|
||||||
|
: undefined;
|
||||||
|
const wagons =
|
||||||
|
physical == null ? room.wagons : Math.min(room.wagons, physical);
|
||||||
|
return { c, leg, room: { ...room, wagons } };
|
||||||
})
|
})
|
||||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||||
@@ -2047,6 +2099,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!offered) return false;
|
if (!offered) return false;
|
||||||
target.c.budget.subtract(offered, target.leg);
|
target.c.budget.subtract(offered, target.leg);
|
||||||
|
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
|
||||||
target.c.armed = true;
|
target.c.armed = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3468,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return dims.length ? dims : [fallback];
|
return dims.length ? dims : [fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical wagon-type stock for one schedule, on the same corridor edges its
|
||||||
|
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
|
||||||
|
* batch counts exactly the wagons the allocator will later plan against.
|
||||||
|
*/
|
||||||
|
private async stockLedgerFor(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
budget: CorridorBudget,
|
||||||
|
): Promise<WagonStockLedger> {
|
||||||
|
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||||
|
schedule.id,
|
||||||
|
schedule.originStationId,
|
||||||
|
budget.stops,
|
||||||
|
);
|
||||||
|
return new WagonStockLedger(
|
||||||
|
stock.remainingByTypeId,
|
||||||
|
Math.max(1, budget.stops.length - 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the train holds enough PHYSICAL wagons of the types this booking may
|
||||||
|
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
|
||||||
|
* abstract budget still governs, and a mis-configured cargo type must not
|
||||||
|
* silently strand every booking that uses it.
|
||||||
|
*/
|
||||||
|
private hasWagonStock(
|
||||||
|
stock: WagonStockLedger,
|
||||||
|
wagonTypeIds: string[],
|
||||||
|
wagonsNeeded: number,
|
||||||
|
leg: CorridorLeg,
|
||||||
|
): boolean {
|
||||||
|
if (!wagonTypeIds.length) return true;
|
||||||
|
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allowedWagonTypeCache: {
|
||||||
|
byCargoTypeId: Map<string, string[]>;
|
||||||
|
byContainerTypeId: Map<string, string[]>;
|
||||||
|
expiresAt: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon-type ids each cargo / container type may ride, read straight from the
|
||||||
|
* join tables.
|
||||||
|
*
|
||||||
|
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
|
||||||
|
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
|
||||||
|
* a hot path. So the pool's booking entities carry the type FK but not the
|
||||||
|
* allowed list, and resolving it per booking through the relation would come
|
||||||
|
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
|
||||||
|
* give the same answer without touching the pool query.
|
||||||
|
*/
|
||||||
|
private async loadAllowedWagonTypeIds(): Promise<{
|
||||||
|
byCargoTypeId: Map<string, string[]>;
|
||||||
|
byContainerTypeId: Map<string, string[]>;
|
||||||
|
}> {
|
||||||
|
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
|
||||||
|
return this.allowedWagonTypeCache;
|
||||||
|
}
|
||||||
|
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
|
||||||
|
// scheduling service — the allocator will not plan against them either.
|
||||||
|
const [cargoRows, containerRows]: [
|
||||||
|
Array<{ typeId: string; wagonTypeId: string }>,
|
||||||
|
Array<{ typeId: string; wagonTypeId: string }>,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||||||
|
FROM freight.cargo_type_wagon_types ct
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||||||
|
WHERE wt.is_active IS NOT FALSE`,
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||||||
|
FROM freight.container_type_wagon_types ct
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||||||
|
WHERE wt.is_active IS NOT FALSE`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = map.get(row.typeId) ?? [];
|
||||||
|
list.push(row.wagonTypeId);
|
||||||
|
map.set(row.typeId, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
byCargoTypeId: collect(cargoRows),
|
||||||
|
byContainerTypeId: collect(containerRows),
|
||||||
|
};
|
||||||
|
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
|
||||||
|
* caller must then skip the physical-stock gate rather than block the booking
|
||||||
|
* on missing configuration.
|
||||||
|
*/
|
||||||
|
private allowedWagonTypeIdsFor(
|
||||||
|
booking: Booking,
|
||||||
|
allowed: {
|
||||||
|
byCargoTypeId: Map<string, string[]>;
|
||||||
|
byContainerTypeId: Map<string, string[]>;
|
||||||
|
},
|
||||||
|
): string[] {
|
||||||
|
if (booking.freightType === "BULK") {
|
||||||
|
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
|
||||||
|
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const line of booking.bookingContainers ?? []) {
|
||||||
|
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||||||
|
if (!containerTypeId) continue;
|
||||||
|
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
@@ -48,6 +49,7 @@ export class BookingJourneyService {
|
|||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly yardFacilities: YardFacilitiesService,
|
private readonly yardFacilities: YardFacilitiesService,
|
||||||
private readonly facilityHandling: FacilityHandlingService,
|
private readonly facilityHandling: FacilityHandlingService,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -145,6 +147,12 @@ export class BookingJourneyService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Intercity ends here — a ONE_TIME contract closes on its shipment being
|
||||||
|
// delivered (import/export emit this from booking-transition.complete).
|
||||||
|
if (nextStatus === 'COMPLETED') {
|
||||||
|
this.events.emit('booking.completed', { bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||||
void this.completeMilestones(booking, [
|
void this.completeMilestones(booking, [
|
||||||
...(booking.tradeDirection === 'IMPORT'
|
...(booking.tradeDirection === 'IMPORT'
|
||||||
@@ -303,6 +311,12 @@ export class BookingJourneyService {
|
|||||||
RETURNING b.id, b.trade_direction`,
|
RETURNING b.id, b.trade_direction`,
|
||||||
[schedule.id, schedule.destinationStationId, now],
|
[schedule.id, schedule.destinationStationId, now],
|
||||||
);
|
);
|
||||||
|
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.trade_direction === 'DOMESTIC') {
|
||||||
|
this.events.emit('booking.completed', { bookingId: row.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
return rows.map((r) => r.id);
|
return rows.map((r) => r.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto {
|
|||||||
type: [String],
|
type: [String],
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description:
|
description:
|
||||||
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
|
'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
locomotiveIds?: string[];
|
locomotiveIds?: string[];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
consistViolations,
|
consistViolations,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
grossWagonWeightTons,
|
grossWagonWeightTons,
|
||||||
minLocomotiveLimits,
|
combinedLocomotiveLimits,
|
||||||
sizePartialOfferWagons,
|
sizePartialOfferWagons,
|
||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
@@ -197,42 +197,75 @@ describe('train-capacity.util', () => {
|
|||||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes the weakest locomotive across a multi-locomotive set', () => {
|
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
|
||||||
const limits = minLocomotiveLimits([
|
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
const limits = combinedLocomotiveLimits([
|
||||||
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||||
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||||
]);
|
]);
|
||||||
expect(limits?.maxPullWeightTons).toBe(3500);
|
expect(limits?.maxPullWeightTons).toBe(3500);
|
||||||
expect(limits?.overageToleranceTons).toBe(20);
|
expect(limits?.overageToleranceTons).toBe(180);
|
||||||
|
// A single locomotive is just its own limit — no doubling, no halving.
|
||||||
|
expect(
|
||||||
|
combinedLocomotiveLimits([
|
||||||
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||||
|
])?.maxPullWeightTons,
|
||||||
|
).toBe(1750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
|
||||||
|
const limits = combinedLocomotiveLimits([
|
||||||
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
|
||||||
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
|
||||||
|
]);
|
||||||
|
expect(limits?.maxTrainLengthMeters).toBe(700);
|
||||||
|
expect(limits?.overageToleranceMeters).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
|
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
|
||||||
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
|
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
|
||||||
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
|
// keep the 90 rather than collapse to 0 — an unset value abstains.
|
||||||
const limits = minLocomotiveLimits([
|
const limits = combinedLocomotiveLimits([
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
|
||||||
]);
|
]);
|
||||||
expect(limits?.overageToleranceTons).toBe(90);
|
expect(limits?.overageToleranceTons).toBe(90);
|
||||||
// All unconfigured → no tolerance.
|
// All unconfigured → no tolerance.
|
||||||
const none = minLocomotiveLimits([
|
const none = combinedLocomotiveLimits([
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||||
]);
|
]);
|
||||||
expect(none?.overageToleranceTons).toBe(0);
|
expect(none?.overageToleranceTons).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports no pull limit when NO locomotive has one configured', () => {
|
||||||
|
// Summing must not turn "unset" into 0 and strand every booking; an
|
||||||
|
// all-unset set keeps the old "no opinion" behaviour.
|
||||||
|
const limits = combinedLocomotiveLimits([
|
||||||
|
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
|
||||||
|
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
|
||||||
|
]);
|
||||||
|
expect(limits?.maxPullWeightTons).toBe(Infinity);
|
||||||
|
// One configured, one not → only the configured one contributes.
|
||||||
|
expect(
|
||||||
|
combinedLocomotiveLimits([
|
||||||
|
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
|
||||||
|
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
|
||||||
|
])?.maxPullWeightTons,
|
||||||
|
).toBe(1750);
|
||||||
|
});
|
||||||
|
|
||||||
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
|
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
|
||||||
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
|
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
|
||||||
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
|
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
|
||||||
expect(
|
expect(
|
||||||
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
|
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
maxPullWeightTons: 3500,
|
maxPullWeightTons: 3550,
|
||||||
maxTrainLengthMeters: 700,
|
maxTrainLengthMeters: 700,
|
||||||
overageToleranceTons: 90,
|
overageToleranceTons: 90,
|
||||||
overageToleranceMeters: 0,
|
overageToleranceMeters: 0,
|
||||||
});
|
});
|
||||||
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
|
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
|
||||||
expect(trainSetLocomotiveLimits(null)).toBeNull();
|
expect(trainSetLocomotiveLimits(null)).toBeNull();
|
||||||
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
|
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -256,27 +256,40 @@ function round3(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective pull limits for a train set with multiple locomotives: the weakest
|
* Effective limits for a train set, per axis:
|
||||||
* locomotive caps the train, so take the minimum pull weight and minimum length
|
*
|
||||||
* across all assigned locomotives. Returns null when no locomotives are given.
|
* - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull
|
||||||
|
* 3500T. Only CONFIGURED pull weights are summed; a set with none configured
|
||||||
|
* reports Infinity (no opinion), exactly as before.
|
||||||
|
* - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its
|
||||||
|
* own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0).
|
||||||
|
* - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not
|
||||||
|
* a haulage one: coupling a second locomotive does not lengthen the track, so
|
||||||
|
* the most restrictive locomotive still governs (and its tolerance with it).
|
||||||
|
*
|
||||||
|
* Returns null when no locomotives are given.
|
||||||
*/
|
*/
|
||||||
export function minLocomotiveLimits(
|
export function combinedLocomotiveLimits(
|
||||||
locomotives: Array<
|
locomotives: Array<
|
||||||
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
|
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
|
||||||
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
|
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
|
||||||
>,
|
>,
|
||||||
): LocomotiveLimits | null {
|
): LocomotiveLimits | null {
|
||||||
if (!locomotives.length) return null;
|
if (!locomotives.length) return null;
|
||||||
|
const configuredPulls = locomotives
|
||||||
|
.map((l) => num(l.maxPullWeightTons))
|
||||||
|
.filter((v) => v > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxPullWeightTons: Math.min(
|
maxPullWeightTons: configuredPulls.length
|
||||||
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
|
? round3(configuredPulls.reduce((sum, v) => sum + v, 0))
|
||||||
),
|
: Infinity,
|
||||||
maxTrainLengthMeters: Math.min(
|
maxTrainLengthMeters: Math.min(
|
||||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||||
),
|
),
|
||||||
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
|
overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)),
|
||||||
// tolerance set has no opinion, it does not zero out the others.
|
// Paired with the length axis, so it stays the weakest CONFIGURED value — a
|
||||||
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
|
// locomotive with no tolerance set has no opinion, it does not zero the others.
|
||||||
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
|
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -286,10 +299,16 @@ function minConfigured(values: Array<number | null | undefined>): number {
|
|||||||
return configured.length ? Math.min(...configured) : 0;
|
return configured.length ? Math.min(...configured) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sumConfigured(values: Array<number | null | undefined>): number {
|
||||||
|
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||||
|
return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective limits for a whole train set: min across its linked locomotives,
|
* Effective limits for a whole train set: {@link combinedLocomotiveLimits} over
|
||||||
* falling back to the legacy single `locomotive` column for sets created
|
* its linked locomotives, falling back to the legacy single `locomotive` column
|
||||||
* before multi-loco support. Null when the set has no locomotive at all.
|
* for sets created before multi-loco support. Null when the set has no
|
||||||
|
* locomotive at all.
|
||||||
*/
|
*/
|
||||||
export function trainSetLocomotiveLimits(
|
export function trainSetLocomotiveLimits(
|
||||||
trainSet?: {
|
trainSet?: {
|
||||||
@@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits(
|
|||||||
: trainSet.locomotive
|
: trainSet.locomotive
|
||||||
? [trainSet.locomotive]
|
? [trainSet.locomotive]
|
||||||
: [];
|
: [];
|
||||||
return minLocomotiveLimits(pool);
|
return combinedLocomotiveLimits(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
|||||||
import {
|
import {
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
minLocomotiveLimits,
|
combinedLocomotiveLimits,
|
||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
@@ -1300,9 +1300,9 @@ export class TrainSchedulingService {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
.map((link) => link.locomotiveId);
|
.map((link) => link.locomotiveId);
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
|
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (builtTrain.currentYardId !== route.originYardId) {
|
if (builtTrain.currentYardId !== route.originYardId) {
|
||||||
@@ -1323,8 +1323,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,7 +1382,7 @@ export class TrainSchedulingService {
|
|||||||
builtTrain?.id ?? null,
|
builtTrain?.id ?? null,
|
||||||
);
|
);
|
||||||
// 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 = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||||
const departure = new Date(dto.scheduleDate);
|
const departure = new Date(dto.scheduleDate);
|
||||||
// Every schedule starts with a CLOSED customer window; the window engine opens
|
// Every schedule starts with a CLOSED customer window; the window engine opens
|
||||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||||
@@ -1572,7 +1572,7 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||||
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
|
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
|
||||||
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||||||
|
|
||||||
// Callers that add bookings without hand-picking container slots (the
|
// Callers that add bookings without hand-picking container slots (the
|
||||||
@@ -3971,36 +3971,12 @@ export class TrainSchedulingService {
|
|||||||
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
|
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
|
||||||
|
|
||||||
const originYardId = dto.originStationId;
|
const originYardId = dto.originStationId;
|
||||||
let stock: WagonStock;
|
const stock: WagonStock = await this.wagonStockForSchedule(
|
||||||
if (builtTrainId) {
|
targetScheduleId,
|
||||||
stock = await this.builtTrainStock(builtTrainId);
|
originYardId,
|
||||||
} else {
|
bookings.map((b) => b.originYardId),
|
||||||
// Dynamic consist: a slot's physical wagon may ride from the train's origin
|
builtTrainId,
|
||||||
// OR already sit at the booking's own boarding yard and attach there — so
|
);
|
||||||
// the usable fleet is the union across the origin and every boarding yard.
|
|
||||||
const boardYardIds = [
|
|
||||||
...new Set(
|
|
||||||
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const fleetCountsByYard = await Promise.all(
|
|
||||||
boardYardIds.map((yardId) =>
|
|
||||||
this.countFleetAvailability(yardId, targetScheduleId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const remainingByTypeId = new Map<string, number>();
|
|
||||||
const codesByTypeId = new Map<string, string>();
|
|
||||||
for (const rows of fleetCountsByYard) {
|
|
||||||
for (const row of rows) {
|
|
||||||
remainingByTypeId.set(
|
|
||||||
row.wagonTypeId,
|
|
||||||
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
|
|
||||||
);
|
|
||||||
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||||||
// so a ride-along on an empty leg never competes with cargo on a full one.
|
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||||||
@@ -4129,7 +4105,7 @@ export class TrainSchedulingService {
|
|||||||
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
||||||
// is a hard violation.
|
// 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 = combinedLocomotiveLimits(assignedLocomotives);
|
||||||
if (offYard) {
|
if (offYard) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||||||
@@ -4799,6 +4775,51 @@ export class TrainSchedulingService {
|
|||||||
* type. This is the whole plannable pool for its schedules — the plan is
|
* type. This is the whole plannable pool for its schedules — the plan is
|
||||||
* full when every consist wagon is allocated.
|
* full when every consist wagon is allocated.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The physical wagons a schedule can actually plan against, by wagon type.
|
||||||
|
*
|
||||||
|
* A schedule built from a Train Builder train plans against ONLY that train's
|
||||||
|
* own consist. A legacy/dynamic-consist schedule plans against the boarding
|
||||||
|
* yards' loose pool: a slot's wagon may ride from the train's origin OR
|
||||||
|
* already sit at the booking's own boarding yard and attach there, so the
|
||||||
|
* usable fleet is the union across the origin and every boarding yard.
|
||||||
|
*
|
||||||
|
* Public because batch fill needs the SAME stock the allocator will later
|
||||||
|
* validate against — selecting a booking the allocator cannot place is how
|
||||||
|
* customers ended up paying for wagons that were never there.
|
||||||
|
*/
|
||||||
|
async wagonStockForSchedule(
|
||||||
|
scheduleId: string | undefined,
|
||||||
|
originYardId: string,
|
||||||
|
boardingYardIds: Array<string | null | undefined> = [],
|
||||||
|
preloadedBuiltTrainId?: string | null,
|
||||||
|
): Promise<WagonStock> {
|
||||||
|
const builtTrainId =
|
||||||
|
preloadedBuiltTrainId !== undefined
|
||||||
|
? preloadedBuiltTrainId
|
||||||
|
: await this.builtTrainIdOfSchedule(scheduleId);
|
||||||
|
if (builtTrainId) return this.builtTrainStock(builtTrainId);
|
||||||
|
|
||||||
|
const boardYardIds = [
|
||||||
|
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
|
||||||
|
];
|
||||||
|
const fleetCountsByYard = await Promise.all(
|
||||||
|
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
|
||||||
|
);
|
||||||
|
const remainingByTypeId = new Map<string, number>();
|
||||||
|
const codesByTypeId = new Map<string, string>();
|
||||||
|
for (const rows of fleetCountsByYard) {
|
||||||
|
for (const row of rows) {
|
||||||
|
remainingByTypeId.set(
|
||||||
|
row.wagonTypeId,
|
||||||
|
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
|
||||||
|
);
|
||||||
|
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||||
|
}
|
||||||
|
|
||||||
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
|
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
|
||||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
where: { trainId: builtTrainId },
|
where: { trainId: builtTrainId },
|
||||||
@@ -5347,7 +5368,7 @@ export class TrainSchedulingService {
|
|||||||
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
|
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
|
||||||
* philosophy: nothing serviceable is filtered out — staff see the status,
|
* philosophy: nothing serviceable is filtered out — staff see the status,
|
||||||
* whether the train sits at the origin yard yet, and its future schedules.
|
* whether the train sits at the origin yard yet, and its future schedules.
|
||||||
* Trains with fewer than two locomotives are omitted (never schedulable).
|
* Trains with no locomotive at all are omitted (never schedulable).
|
||||||
*/
|
*/
|
||||||
async getAvailableTrainsForRoute(routeId: string) {
|
async getAvailableTrainsForRoute(routeId: string) {
|
||||||
const route = await this.getSchedulableRoute(routeId);
|
const route = await this.getSchedulableRoute(routeId);
|
||||||
@@ -5385,7 +5406,7 @@ export class TrainSchedulingService {
|
|||||||
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
|
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
|
||||||
|
|
||||||
return trains
|
return trains
|
||||||
.filter((train) => (train.locomotives ?? []).length >= 2)
|
.filter((train) => (train.locomotives ?? []).length >= 1)
|
||||||
.map((train) => {
|
.map((train) => {
|
||||||
const wagons = train.wagons ?? [];
|
const wagons = train.wagons ?? [];
|
||||||
return {
|
return {
|
||||||
@@ -5417,7 +5438,15 @@ export class TrainSchedulingService {
|
|||||||
totalLengthMeters: roundTons(
|
totalLengthMeters: roundTons(
|
||||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||||
),
|
),
|
||||||
maxPullWeightTons: roundTons(Number(train.capacityTons)),
|
// Live from the coupled set — `capacity_tons` still holds the old
|
||||||
|
// single-locomotive figure on trains built before pull weight summed.
|
||||||
|
maxPullWeightTons: roundTons(
|
||||||
|
combinedLocomotiveLimits(
|
||||||
|
(train.locomotives ?? [])
|
||||||
|
.map((link) => link.locomotive)
|
||||||
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||||
|
)?.maxPullWeightTons ?? Number(train.capacityTons),
|
||||||
|
),
|
||||||
atOriginYard: train.currentYardId === route.originYardId,
|
atOriginYard: train.currentYardId === route.originYardId,
|
||||||
futureScheduleCount: futureCounts.get(train.id) ?? 0,
|
futureScheduleCount: futureCounts.get(train.id) ?? 0,
|
||||||
};
|
};
|
||||||
@@ -5467,7 +5496,7 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
||||||
|
|
||||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||||
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
|
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
|
||||||
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
|
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
|
||||||
@@ -5590,7 +5619,7 @@ export class TrainSchedulingService {
|
|||||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||||
.map((slot) => slot.physicalWagonId as string),
|
.map((slot) => slot.physicalWagonId as string),
|
||||||
);
|
);
|
||||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||||
const pullCapTons = roundTons(
|
const pullCapTons = roundTons(
|
||||||
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||||
|
|
||||||
|
const WHOLE = { fromEdge: 0, toEdge: 1 };
|
||||||
|
|
||||||
|
describe('WagonStockLedger', () => {
|
||||||
|
it('reports the wagons of a booking\'s OWN types, not the train total', () => {
|
||||||
|
// The reported case: 20 free wagons on the train, but only 16 of them NW5.
|
||||||
|
const ledger = new WagonStockLedger(
|
||||||
|
new Map([
|
||||||
|
['nw5', 16],
|
||||||
|
['pw2', 4],
|
||||||
|
]),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16);
|
||||||
|
expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4);
|
||||||
|
// A cargo type mapped to both may ride either, so they add up.
|
||||||
|
expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20);
|
||||||
|
// Duplicates must not double-count.
|
||||||
|
expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16);
|
||||||
|
// An unconfigured type has no stock.
|
||||||
|
expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes what it can and reports the shortfall', () => {
|
||||||
|
const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1);
|
||||||
|
// A 20-wagon booking can only take 16 — the caller splits on that number.
|
||||||
|
expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16);
|
||||||
|
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
|
||||||
|
expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains the deepest stock first across candidate types', () => {
|
||||||
|
const ledger = new WagonStockLedger(
|
||||||
|
new Map([
|
||||||
|
['nw5', 10],
|
||||||
|
['nw7', 3],
|
||||||
|
]),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12);
|
||||||
|
// 10 from NW5 then 2 from NW7 — one NW7 left.
|
||||||
|
expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1);
|
||||||
|
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frees stock past an alight yard — disjoint legs never compete', () => {
|
||||||
|
// Three stops (A→B→C) = two edges. An intercity booking riding A→B must
|
||||||
|
// not consume the wagon on B→C.
|
||||||
|
const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2);
|
||||||
|
const firstLeg = { fromEdge: 0, toEdge: 1 };
|
||||||
|
const secondLeg = { fromEdge: 1, toEdge: 2 };
|
||||||
|
|
||||||
|
ledger.consume(['nw5'], 5, firstLeg);
|
||||||
|
expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0);
|
||||||
|
expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5);
|
||||||
|
|
||||||
|
// A whole-route booking sees the busiest edge it crosses, so it is blocked.
|
||||||
|
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the busiest edge within a leg, not the sum of edges', () => {
|
||||||
|
const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3);
|
||||||
|
ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 });
|
||||||
|
ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 });
|
||||||
|
// Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once.
|
||||||
|
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4);
|
||||||
|
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { CorridorLeg } from './corridor-capacity.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical wagon-type stock for one train, consumed per corridor edge.
|
||||||
|
*
|
||||||
|
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
|
||||||
|
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
|
||||||
|
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
|
||||||
|
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
|
||||||
|
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
|
||||||
|
* yard" — money taken for space that never existed.
|
||||||
|
*
|
||||||
|
* This ledger is the missing axis: how many wagons of the types a booking may
|
||||||
|
* actually ride are free. Batch fill consults it alongside the budget, so a
|
||||||
|
* booking is admitted whole only when both agree, and is otherwise offered a
|
||||||
|
* split sized to the wagons that genuinely exist.
|
||||||
|
*
|
||||||
|
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
|
||||||
|
* an alight yard is available again downstream, so an intercity ride-along on
|
||||||
|
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
|
||||||
|
*/
|
||||||
|
export class WagonStockLedger {
|
||||||
|
private readonly usedPerEdge = new Map<string, number[]>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly remainingByTypeId: Map<string, number>,
|
||||||
|
private readonly edgeCount: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
|
||||||
|
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
|
||||||
|
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||||
|
const row = this.usedPerEdge.get(wagonTypeId);
|
||||||
|
if (!row) return total;
|
||||||
|
let busiest = 0;
|
||||||
|
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
||||||
|
busiest = Math.max(busiest, row[edge] ?? 0);
|
||||||
|
}
|
||||||
|
return Math.max(0, total - busiest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free wagons across every type a booking may ride. A cargo/container type
|
||||||
|
* mapped to several wagon types can use any of them, so they add up.
|
||||||
|
*/
|
||||||
|
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const id of new Set(wagonTypeIds)) {
|
||||||
|
total += this.availableForType(id, leg);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take `wagons` from the candidate types, deepest stock first so the consist
|
||||||
|
* drains evenly (same tie-break as the wagon planner). Returns how many were
|
||||||
|
* actually taken — less than asked when the stock is short.
|
||||||
|
*/
|
||||||
|
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
|
||||||
|
let outstanding = Math.max(0, Math.floor(wagons));
|
||||||
|
const candidates = [...new Set(wagonTypeIds)];
|
||||||
|
let taken = 0;
|
||||||
|
|
||||||
|
while (outstanding > 0) {
|
||||||
|
const deepest = candidates
|
||||||
|
.map((id) => ({ id, free: this.availableForType(id, leg) }))
|
||||||
|
.filter((c) => c.free > 0)
|
||||||
|
.sort((a, b) => b.free - a.free)[0];
|
||||||
|
if (!deepest) break;
|
||||||
|
|
||||||
|
const take = Math.min(outstanding, deepest.free);
|
||||||
|
let row = this.usedPerEdge.get(deepest.id);
|
||||||
|
if (!row) {
|
||||||
|
row = new Array<number>(this.edgeCount).fill(0);
|
||||||
|
this.usedPerEdge.set(deepest.id, row);
|
||||||
|
}
|
||||||
|
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
||||||
|
row[edge] = (row[edge] ?? 0) + take;
|
||||||
|
}
|
||||||
|
outstanding -= take;
|
||||||
|
taken += take;
|
||||||
|
}
|
||||||
|
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Link row joining a train set to one of its locomotives. A train set must be
|
* Link row joining a train set to one of its locomotives. A train set must be
|
||||||
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
|
* pulled by at least one locomotive; `sequenceNo` is a plain
|
||||||
* order index — no front/rear semantics are modelled yet.
|
* order index — no front/rear semantics are modelled yet.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
|
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'locomotive_id' })
|
@JoinColumn({ name: 'locomotive_id' })
|
||||||
locomotive?: Locomotive;
|
locomotive?: Locomotive;
|
||||||
|
|
||||||
/** All locomotives pulling this train set (minimum 2). */
|
/** All locomotives pulling this train set (minimum 1). */
|
||||||
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
|
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
|
||||||
locomotives?: TrainSetLocomotive[];
|
locomotives?: TrainSetLocomotive[];
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export class BuildTrainDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [String],
|
type: [String],
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
|
description: 'Locomotives pulling the train (minimum 1), in consist order',
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
locomotiveIds!: string[];
|
locomotiveIds!: string[];
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [String],
|
type: [String],
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description: 'Full replacement locomotive set (minimum 2), in consist order',
|
description: 'Full replacement locomotive set (minimum 1), in consist order',
|
||||||
})
|
})
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
|
||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
locomotiveIds!: string[];
|
locomotiveIds!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Train } from './train.entity';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Link row joining a built train to one of its locomotives. A train must be
|
* Link row joining a built train to one of its locomotives. A train must be
|
||||||
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
|
* pulled by at least one locomotive; `sequenceNo` is the order
|
||||||
* in the consist — 0 is the lead locomotive.
|
* in the consist — 0 is the lead locomotive.
|
||||||
*
|
*
|
||||||
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
|
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class Train extends BaseEntity {
|
|||||||
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
||||||
wagons!: Wagon[];
|
wagons!: Wagon[];
|
||||||
|
|
||||||
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
|
/** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */
|
||||||
@OneToMany(() => TrainLocomotive, (link) => link.train)
|
@OneToMany(() => TrainLocomotive, (link) => link.train)
|
||||||
locomotives?: TrainLocomotive[];
|
locomotives?: TrainLocomotive[];
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ export class TrainBuilderController {
|
|||||||
|
|
||||||
@Put(':id/locomotives')
|
@Put(':id/locomotives')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
||||||
setLocomotives(
|
setLocomotives(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateTrainLocomotivesDto,
|
@Body() dto: UpdateTrainLocomotivesDto,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||||
@@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
|
|||||||
|
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
@@ -55,12 +56,14 @@ export interface ActiveScheduleRef {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainBuilderService {
|
export class TrainBuilderService {
|
||||||
|
private readonly logger = new Logger(TrainBuilderService.name);
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
async buildTrain(dto: BuildTrainDto) {
|
async buildTrain(dto: BuildTrainDto) {
|
||||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||||
}
|
}
|
||||||
|
|
||||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -96,7 +99,7 @@ export class TrainBuilderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Effective haul capacity is capped by the weakest locomotive in the set.
|
// Effective haul capacity is capped by the weakest locomotive in the set.
|
||||||
const limits = minLocomotiveLimits(locomotives);
|
const limits = combinedLocomotiveLimits(locomotives);
|
||||||
const train = await manager.getRepository(Train).save(
|
const train = await manager.getRepository(Train).save(
|
||||||
manager.getRepository(Train).create({
|
manager.getRepository(Train).create({
|
||||||
code,
|
code,
|
||||||
@@ -283,7 +286,7 @@ export class TrainBuilderService {
|
|||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const limits = minLocomotiveLimits(
|
const limits = combinedLocomotiveLimits(
|
||||||
(train.locomotives ?? [])
|
(train.locomotives ?? [])
|
||||||
.map((link) => link.locomotive)
|
.map((link) => link.locomotive)
|
||||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||||
@@ -339,11 +342,11 @@ export class TrainBuilderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
|
/** Replace the locomotive set (minimum 1, same-yard rule applies). */
|
||||||
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
|
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
|
||||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||||
}
|
}
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
@@ -360,7 +363,7 @@ export class TrainBuilderService {
|
|||||||
train.id,
|
train.id,
|
||||||
);
|
);
|
||||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||||
const limits = minLocomotiveLimits(locomotives);
|
const limits = combinedLocomotiveLimits(locomotives);
|
||||||
await manager
|
await manager
|
||||||
.getRepository(Train)
|
.getRepository(Train)
|
||||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||||
@@ -520,6 +523,28 @@ export class TrainBuilderService {
|
|||||||
sequenceNumber: null,
|
sequenceNumber: null,
|
||||||
status: WagonStatus.Maintenance,
|
status: WagonStatus.Maintenance,
|
||||||
});
|
});
|
||||||
|
// Audit row: which train it came off and when. The wagon does not change
|
||||||
|
// yard here, so from/to are the same — the ledger is the wagon's history
|
||||||
|
// surface, and a maintenance detach has to be in it.
|
||||||
|
const yardId = wagon.currentYardId ?? train.currentYardId ?? null;
|
||||||
|
if (yardId) {
|
||||||
|
await manager.getRepository(WagonMovement).save(
|
||||||
|
manager.getRepository(WagonMovement).create({
|
||||||
|
wagonId: wagon.id,
|
||||||
|
fromYardId: yardId,
|
||||||
|
toYardId: yardId,
|
||||||
|
kind: WagonMovementKind.Maintenance,
|
||||||
|
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance,
|
||||||
|
// it just cannot carry a ledger row.
|
||||||
|
this.logger.warn(
|
||||||
|
`Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.resequenceWagons(manager, train.id);
|
await this.resequenceWagons(manager, train.id);
|
||||||
});
|
});
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
@@ -743,7 +768,16 @@ export class TrainBuilderService {
|
|||||||
totalLengthMeters: round(
|
totalLengthMeters: round(
|
||||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||||
),
|
),
|
||||||
maxPullWeightTons: round(train.capacityTons),
|
// Derived live from the coupled set, NOT from the stored capacity_tons.
|
||||||
|
// That column is written at build/re-couple time, so every train built
|
||||||
|
// before pull weight became additive still holds the old single-locomotive
|
||||||
|
// figure. Computing it here keeps the board honest without a backfill;
|
||||||
|
// the column self-heals the next time the locomotive set is saved.
|
||||||
|
maxPullWeightTons: round(
|
||||||
|
combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ??
|
||||||
|
Number(train.capacityTons) ??
|
||||||
|
0,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +912,7 @@ export class TrainBuilderService {
|
|||||||
where: { trainId: train.id },
|
where: { trainId: train.id },
|
||||||
relations: { locomotive: true },
|
relations: { locomotive: true },
|
||||||
});
|
});
|
||||||
const limits = minLocomotiveLimits(
|
const limits = combinedLocomotiveLimits(
|
||||||
links
|
links
|
||||||
.map((link) => link.locomotive)
|
.map((link) => link.locomotive)
|
||||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
// Each has its own permission so the two desks are genuinely separate people.
|
// Each has its own permission so the two desks are genuinely separate people.
|
||||||
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
|
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
|
||||||
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
|
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
|
||||||
|
// Freeze/unfreeze a signed contract. One key covers both directions — whoever
|
||||||
|
// may suspend must be able to lift it again.
|
||||||
|
perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Existing per-slug view ids are kept as-is: position-type grants reference
|
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||||
@@ -447,6 +450,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions',
|
clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions',
|
||||||
clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions',
|
clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions',
|
||||||
clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise',
|
clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise',
|
||||||
|
suspend: 'edr_freight_app:contracts:suspend',
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
@@ -897,6 +901,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||||
|
FREIGHT_PERMS.contracts.suspend,
|
||||||
],
|
],
|
||||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
// Operations hub: clearance-document review for contracts WITHOUT
|
// Operations hub: per-shipment clearance-document review for services
|
||||||
// customs clearing (contract-level for one-time, per-booking for general).
|
// WITHOUT customs clearing (self-clearance) — bookings only.
|
||||||
{
|
{
|
||||||
label: "Clearance Documents",
|
label: "Clearance Documents",
|
||||||
href: "/dashboard/contracts/clearance-documents",
|
href: "/dashboard/contracts/clearance-documents",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
FilePen,
|
FilePen,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
|
PauseCircle,
|
||||||
|
PlayCircle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -43,6 +45,21 @@ const CLEARANCE_REVIEW_STATUSES = [
|
|||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every step from the customer signature onward can be frozen. Mirrors
|
||||||
|
* SUSPENDABLE_CONTRACT_STATUSES on the API — the server is the authority, this
|
||||||
|
* list only decides whether the button is drawn.
|
||||||
|
*/
|
||||||
|
const SUSPENDABLE_STATUSES = [
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"CONTRACT_ACTIVE",
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
|
];
|
||||||
|
|
||||||
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
||||||
export function ContractActionsToolbar({
|
export function ContractActionsToolbar({
|
||||||
contract,
|
contract,
|
||||||
@@ -62,6 +79,8 @@ export function ContractActionsToolbar({
|
|||||||
FREIGHT_PERMS.contracts.requestChanges[arm],
|
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||||
);
|
);
|
||||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
|
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||||
|
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
@@ -70,6 +89,10 @@ export function ContractActionsToolbar({
|
|||||||
const [changesNote, setChangesNote] = useState("");
|
const [changesNote, setChangesNote] = useState("");
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState("");
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [suspendOpen, setSuspendOpen] = useState(false);
|
||||||
|
const [suspendReason, setSuspendReason] = useState("");
|
||||||
|
const [resumeOpen, setResumeOpen] = useState(false);
|
||||||
|
const [resumeNote, setResumeNote] = useState("");
|
||||||
|
|
||||||
// Whether the document is editable depends on WHO is viewing — only the
|
// Whether the document is editable depends on WHO is viewing — only the
|
||||||
// approver whose turn it is may edit — so the server decides, not the client.
|
// approver whose turn it is may edit — so the server decides, not the client.
|
||||||
@@ -110,6 +133,86 @@ export function ContractActionsToolbar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frozen: nothing on this contract moves — no new bookings, no progress on
|
||||||
|
// the shipments already under it — until the suspension is lifted, which
|
||||||
|
// returns the contract to the status it was suspended at.
|
||||||
|
if (status === "SUSPENDED") {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={PauseCircle} title="Contract suspended">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
This contract is frozen. New bookings are blocked and its existing
|
||||||
|
shipments cannot progress.
|
||||||
|
{contract.statusBeforeSuspension
|
||||||
|
? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
{contract.latestSuspensionNote && (
|
||||||
|
<Text size="sm">
|
||||||
|
<b>Reason:</b> {contract.latestSuspensionNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{maySuspend ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<PlayCircle size={16} />}
|
||||||
|
onClick={() => setResumeOpen(true)}
|
||||||
|
>
|
||||||
|
Lift suspension
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
You do not have permission to lift a suspension.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={resumeOpen}
|
||||||
|
onClose={() => setResumeOpen(false)}
|
||||||
|
title="Lift suspension?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> will return to{" "}
|
||||||
|
<b>{contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"}</b> and
|
||||||
|
the customer will be notified. Bookings on it resume immediately.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Note (optional)"
|
||||||
|
placeholder="Why the suspension is being lifted…"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={resumeNote}
|
||||||
|
onChange={(e) => setResumeNote(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setResumeOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={mutations.resume.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.resume.mutate(resumeNote.trim() || undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setResumeOpen(false);
|
||||||
|
setResumeNote("");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Lift suspension
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const canAccept =
|
const canAccept =
|
||||||
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||||
// The document stays editable for the whole approval chain, but only by the
|
// The document stays editable for the whole approval chain, but only by the
|
||||||
@@ -130,6 +233,7 @@ export function ContractActionsToolbar({
|
|||||||
const clearanceReviewer = contract.customsClearingEnabled
|
const clearanceReviewer = contract.customsClearingEnabled
|
||||||
? "Review clearance (GL)"
|
? "Review clearance (GL)"
|
||||||
: "Review clearance (Ops)";
|
: "Review clearance (Ops)";
|
||||||
|
const canSuspend = maySuspend && SUSPENDABLE_STATUSES.includes(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Zap} title="Staff actions">
|
<SectionCard icon={Zap} title="Staff actions">
|
||||||
@@ -241,10 +345,23 @@ export function ContractActionsToolbar({
|
|||||||
{/* GL "Create booking" removed for now — clearance ends at finalize and
|
{/* GL "Create booking" removed for now — clearance ends at finalize and
|
||||||
the customer creates the booking in the portal. */}
|
the customer creates the booking in the portal. */}
|
||||||
|
|
||||||
|
{canSuspend && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<PauseCircle size={16} />}
|
||||||
|
onClick={() => setSuspendOpen(true)}
|
||||||
|
>
|
||||||
|
Suspend contract
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!canAccept &&
|
{!canAccept &&
|
||||||
!inApproval &&
|
!inApproval &&
|
||||||
!canViewContract &&
|
!canViewContract &&
|
||||||
!canReviewClearance && (
|
!canReviewClearance &&
|
||||||
|
!canSuspend && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No staff actions available for this status. Monitor until the
|
No staff actions available for this status. Monitor until the
|
||||||
workflow advances.
|
workflow advances.
|
||||||
@@ -315,6 +432,51 @@ export function ContractActionsToolbar({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Suspend — freezes the contract AND every shipment under it */}
|
||||||
|
<Modal
|
||||||
|
opened={suspendOpen}
|
||||||
|
onClose={() => setSuspendOpen(false)}
|
||||||
|
title="Suspend this contract?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> will be frozen at its current
|
||||||
|
step (<b>{status}</b>). No new shipments can be booked and the
|
||||||
|
shipments already under it stop moving until the suspension is
|
||||||
|
lifted. The customer is notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for suspension"
|
||||||
|
placeholder="Explain why this contract is being suspended…"
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={suspendReason}
|
||||||
|
onChange={(e) => setSuspendReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setSuspendOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
disabled={!suspendReason.trim()}
|
||||||
|
loading={mutations.suspend.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.suspend.mutate(suspendReason, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuspendOpen(false);
|
||||||
|
setSuspendReason("");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Suspend contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Reject */}
|
{/* Reject */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={rejectOpen}
|
opened={rejectOpen}
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ const isHazardStep = (requiredRole: string): boolean =>
|
|||||||
const roleLabel = (requiredRole: string): string =>
|
const roleLabel = (requiredRole: string): string =>
|
||||||
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
|
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
|
||||||
|
|
||||||
|
/** When the approver acted — "27 Jul 2026, 18:18". */
|
||||||
|
const fmtActedAt = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useContractMutations>;
|
type Mutations = ReturnType<typeof useContractMutations>;
|
||||||
|
|
||||||
interface ContractApprovalStepsCardProps {
|
interface ContractApprovalStepsCardProps {
|
||||||
@@ -349,6 +360,10 @@ function StepRow({
|
|||||||
? "edr-green"
|
? "edr-green"
|
||||||
: "gray";
|
: "gray";
|
||||||
const hazard = isHazardStep(step.requiredRole);
|
const hazard = isHazardStep(step.requiredRole);
|
||||||
|
// A send-back wipes acted_at with the status, so a re-opened step shows no
|
||||||
|
// stale timestamp.
|
||||||
|
const acted =
|
||||||
|
step.actedAt && step.status !== "PENDING" ? fmtActedAt(step.actedAt) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -412,6 +427,14 @@ function StepRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{/* Decided steps carry their verdict time — the chain doubles as an
|
||||||
|
audit trail, so "who was waiting on whom, and for how long" has to
|
||||||
|
be readable without opening the revision history. */}
|
||||||
|
{acted && (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{step.status === "REJECTED" ? "Rejected" : "Approved"} {acted}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{step.note && (
|
{step.note && (
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{step.note}
|
{step.note}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ function newArticleId(): string {
|
|||||||
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Midnight today — the earliest day a contract's validity may start. */
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditableArticle {
|
interface EditableArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -118,6 +125,14 @@ export function ContractDocumentEditorModal({
|
|||||||
);
|
);
|
||||||
}, [opened, draft]);
|
}, [opened, draft]);
|
||||||
|
|
||||||
|
// Accept mode opens on today — a contract never starts in the past, and the
|
||||||
|
// pickers below refuse earlier days.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || mode !== "accept") return;
|
||||||
|
setValidityStart(startOfToday());
|
||||||
|
setValidityEnd(null);
|
||||||
|
}, [opened, mode]);
|
||||||
|
|
||||||
// Default validity to the first configured option (accept mode).
|
// Default validity to the first configured option (accept mode).
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||||
@@ -419,6 +434,7 @@ export function ContractDocumentEditorModal({
|
|||||||
placeholder="Contract validity start"
|
placeholder="Contract validity start"
|
||||||
value={validityStart}
|
value={validityStart}
|
||||||
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
||||||
|
minDate={startOfToday()}
|
||||||
maxDate={validityEnd ?? undefined}
|
maxDate={validityEnd ?? undefined}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -427,7 +443,7 @@ export function ContractDocumentEditorModal({
|
|||||||
placeholder="Contract validity end"
|
placeholder="Contract validity end"
|
||||||
value={validityEnd}
|
value={validityEnd}
|
||||||
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
||||||
minDate={validityStart ?? undefined}
|
minDate={validityStart ?? startOfToday()}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1018,14 +1018,9 @@ export default function GlCreateBookingForm() {
|
|||||||
// Non-fatal
|
// Non-fatal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contract.contractKind === "GENERAL") {
|
// Clearance is always per booking — land on that booking's clearance
|
||||||
// GENERAL per-booking clearance: land on the booking's clearance
|
// detail, the same page the hub opens.
|
||||||
// detail — the same page the Shipments tab on the hub opens.
|
navigate(`/dashboard/clearance/${booking.id}`);
|
||||||
navigate(`/dashboard/clearance/${booking.id}`);
|
|
||||||
} else {
|
|
||||||
// ONE_TIME customs keeps its clearance on the contract.
|
|
||||||
navigate(`/dashboard/contracts/clearance/${contract.id}`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1067,7 +1062,13 @@ export default function GlCreateBookingForm() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<ChevronLeft size={16} />}
|
leftSection={<ChevronLeft size={16} />}
|
||||||
onClick={() => navigate(`/dashboard/contracts/clearance/${contract.id}`)}
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
completeBookingId
|
||||||
|
? `/dashboard/clearance/${completeBookingId}`
|
||||||
|
: "/dashboard/contracts/clearance",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Back to clearance
|
Back to clearance
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const KIND_META: Record<string, { label: string; color: string; icon: ReactNode
|
|||||||
color: "orange",
|
color: "orange",
|
||||||
icon: <Wrench size={14} />,
|
icon: <Wrench size={14} />,
|
||||||
},
|
},
|
||||||
|
MAINTENANCE: {
|
||||||
|
label: "Sent to maintenance",
|
||||||
|
color: "red",
|
||||||
|
icon: <Wrench size={14} />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const yardLabel = (
|
const yardLabel = (
|
||||||
@@ -103,10 +108,16 @@ const WagonMovementHistoryModal = ({
|
|||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{from}
|
{from}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowRight size={13} />
|
{/* Status events (maintenance) sit in one yard — an arrow
|
||||||
<Text size="sm" fw={600}>
|
pointing at the same yard reads as a broken row. */}
|
||||||
{to}
|
{movement.fromYardId !== movement.toYardId && (
|
||||||
</Text>
|
<>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{to}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge size="xs" variant="light" color={meta.color}>
|
<Badge size="xs" variant="light" color={meta.color}>
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const parseError = (error: unknown, fallback: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Step one of the Train Builder: pick the yard it is being assembled in and
|
* Step one of the Train Builder: pick the yard it is being assembled in and
|
||||||
* couple at least two locomotives from that yard. The train code is assigned by
|
* couple at least one locomotive from that yard. The train code is assigned by
|
||||||
* the system. Wagons are attached afterwards on the composition page.
|
* the system. Wagons are attached afterwards on the composition page.
|
||||||
*/
|
*/
|
||||||
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
||||||
@@ -86,9 +86,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
const handleBuild = async () => {
|
const handleBuild = async () => {
|
||||||
if (!yardId || locomotiveIds.length < 2) {
|
if (!yardId || locomotiveIds.length < 1) {
|
||||||
toast({
|
toast({
|
||||||
title: "Pick a yard and couple at least two locomotives",
|
title: "Pick a yard and couple at least one locomotive",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -185,17 +185,15 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Locomotives"
|
label="Locomotives"
|
||||||
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
|
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
|
||||||
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
|
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
|
||||||
data={locomotiveOptions}
|
data={locomotiveOptions}
|
||||||
value={locomotiveIds}
|
value={locomotiveIds}
|
||||||
onChange={setLocomotiveIds}
|
onChange={setLocomotiveIds}
|
||||||
searchable
|
searchable
|
||||||
disabled={!yardId}
|
disabled={!yardId}
|
||||||
error={
|
error={
|
||||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||||
? "Select at least two locomotives"
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
nothingFoundMessage={
|
nothingFoundMessage={
|
||||||
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const parseError = (error: unknown, fallback: string) => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
|
/** Swap the locomotive set of a built train (minimum 1, same-yard rule). */
|
||||||
export default function ChangeLocomotivesModal({
|
export default function ChangeLocomotivesModal({
|
||||||
composition,
|
composition,
|
||||||
opened,
|
opened,
|
||||||
@@ -74,8 +74,8 @@ export default function ChangeLocomotivesModal({
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!composition) return;
|
if (!composition) return;
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 1) {
|
||||||
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
|
toast({ title: "A train needs at least one locomotive", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -112,9 +112,7 @@ export default function ChangeLocomotivesModal({
|
|||||||
onChange={setLocomotiveIds}
|
onChange={setLocomotiveIds}
|
||||||
searchable
|
searchable
|
||||||
error={
|
error={
|
||||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||||
? "Select at least two locomotives"
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
nothingFoundMessage="No available locomotives in this yard"
|
nothingFoundMessage="No available locomotives in this yard"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { type ReactNode } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||||
|
import { wagonTypeColor } from "./trainStatus";
|
||||||
|
|
||||||
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
|
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
|
||||||
const PortalAwareRow = ({
|
const PortalAwareRow = ({
|
||||||
@@ -58,11 +59,36 @@ export default function ConsistWagonList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legend of the types actually coupled, in consist order — the colour code is
|
||||||
|
// only readable if the row tints are keyed somewhere.
|
||||||
|
const legend = [
|
||||||
|
...new Map(
|
||||||
|
wagons
|
||||||
|
.filter((w) => w.wagonType)
|
||||||
|
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||||
{(dropProvided) => (
|
{(dropProvided) => (
|
||||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||||
|
{legend.length > 1 ? (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{legend.map((type) => (
|
||||||
|
<Badge
|
||||||
|
key={type.code}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={wagonTypeColor(type.code)}
|
||||||
|
>
|
||||||
|
{type.code} · {type.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
{wagons.map((wagon, index) => (
|
{wagons.map((wagon, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={wagon.id}
|
key={wagon.id}
|
||||||
@@ -97,8 +123,8 @@ export interface ConsistWagonListProps {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
onReorder: (wagonIds: string[]) => void;
|
onReorder: (wagonIds: string[]) => void;
|
||||||
onRemove: (wagonId: string) => void;
|
onRemove: (wagonId: string) => void;
|
||||||
/** Detach the wagon and move it to MAINTENANCE status. */
|
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
|
||||||
onMaintenance: (wagonId: string) => void;
|
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +145,10 @@ function WagonRow({
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onRemove: (wagonId: string) => void;
|
onRemove: (wagonId: string) => void;
|
||||||
onMaintenance: (wagonId: string) => void;
|
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const color = wagonTypeColor(wagon.wagonType?.code);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalAwareRow snapshot={snapshot}>
|
<PortalAwareRow snapshot={snapshot}>
|
||||||
<Group
|
<Group
|
||||||
@@ -132,9 +160,14 @@ function WagonRow({
|
|||||||
p="sm"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
...dragProvided.draggableProps.style,
|
...dragProvided.draggableProps.style,
|
||||||
border: "1px solid var(--mantine-color-gray-3)",
|
border: `1px solid var(--mantine-color-${color}-2)`,
|
||||||
|
borderLeft: `4px solid var(--mantine-color-${color}-5)`,
|
||||||
borderRadius: "var(--mantine-radius-md)",
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
// Tinted by wagon type so a mixed consist is scannable at a glance;
|
||||||
|
// the drag state keeps its own neutral lift.
|
||||||
|
background: snapshot.isDragging
|
||||||
|
? "white"
|
||||||
|
: `var(--mantine-color-${color}-0)`,
|
||||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
@@ -145,13 +178,20 @@ function WagonRow({
|
|||||||
<GripVertical size={18} />
|
<GripVertical size={18} />
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
<Badge variant="light" color="gray" size="sm">
|
<Badge variant="filled" color={color} size="sm">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
<Group gap={6} wrap="nowrap">
|
||||||
{wagon.wagonNumber}
|
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||||
</Text>
|
{wagon.wagonNumber}
|
||||||
|
</Text>
|
||||||
|
{wagon.wagonType ? (
|
||||||
|
<Badge variant="light" color={color} size="xs" radius="sm">
|
||||||
|
{wagon.wagonType.code}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{wagon.wagonType
|
{wagon.wagonType
|
||||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||||
@@ -165,7 +205,7 @@ function WagonRow({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="orange"
|
color="orange"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onMaintenance(wagon.id)}
|
onClick={() => onMaintenance(wagon)}
|
||||||
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
||||||
>
|
>
|
||||||
<Wrench size={16} />
|
<Wrench size={16} />
|
||||||
|
|||||||
@@ -56,6 +56,58 @@ export const locomotiveStatusLabel = (status: string): string =>
|
|||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/^\w/, (c) => c.toUpperCase());
|
.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hues for the wagon-type color code. No red or gray — red reads as a fault on
|
||||||
|
* a consist row, gray is the "unknown type" fallback.
|
||||||
|
*/
|
||||||
|
const WAGON_TYPE_COLORS = [
|
||||||
|
"blue",
|
||||||
|
"teal",
|
||||||
|
"grape",
|
||||||
|
"orange",
|
||||||
|
"cyan",
|
||||||
|
"indigo",
|
||||||
|
"pink",
|
||||||
|
"lime",
|
||||||
|
"violet",
|
||||||
|
"yellow",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed hue per seeded wagon-type code. Related families sit on neighbouring
|
||||||
|
* hues (gondolas blue/indigo, hoppers grape/violet, flats teal/lime) so a
|
||||||
|
* consist reads as groups, not confetti. Explicit rather than hashed because
|
||||||
|
* hashing 10 codes into 10 hues collides — and two types sharing a colour is
|
||||||
|
* exactly what a colour code must not do.
|
||||||
|
*/
|
||||||
|
const WAGON_TYPE_CODE_COLORS: Record<string, string> = {
|
||||||
|
CW3: "blue", // Gondola open
|
||||||
|
CW4: "indigo", // Gondola covered
|
||||||
|
KW2: "grape", // Hopper covered
|
||||||
|
KW3: "violet", // Hopper open
|
||||||
|
NW5: "teal", // Flat
|
||||||
|
NW6: "lime", // Flat (long)
|
||||||
|
NW7: "pink", // Double deck sedan
|
||||||
|
BW1: "cyan", // Refrigerated
|
||||||
|
GW2: "orange", // Tank
|
||||||
|
PW2: "yellow", // Box
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable hue per wagon-type code. Unseeded codes fall back to a hash so a new
|
||||||
|
* type still gets a consistent colour instead of collapsing to gray.
|
||||||
|
*/
|
||||||
|
export const wagonTypeColor = (code?: string | null): string => {
|
||||||
|
if (!code) return "gray";
|
||||||
|
const seeded = WAGON_TYPE_CODE_COLORS[code];
|
||||||
|
if (seeded) return seeded;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < code.length; i++) {
|
||||||
|
hash = (hash * 31 + code.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return WAGON_TYPE_COLORS[hash % WAGON_TYPE_COLORS.length]!;
|
||||||
|
};
|
||||||
|
|
||||||
/** Badge color per trade direction (Mantine palette keys). */
|
/** Badge color per trade direction (Mantine palette keys). */
|
||||||
export const directionColor = (direction?: string | null): string =>
|
export const directionColor = (direction?: string | null): string =>
|
||||||
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
|
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export const URL_CONSTANTS = {
|
|||||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||||
`/contracts/${id}/staff/request-changes`,
|
`/contracts/${id}/staff/request-changes`,
|
||||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||||
|
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||||
|
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||||
APPROVE_STEP: (id: string, stepId: string) =>
|
APPROVE_STEP: (id: string, stepId: string) =>
|
||||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||||
REJECT_STEP: (id: string, stepId: string) =>
|
REJECT_STEP: (id: string, stepId: string) =>
|
||||||
@@ -217,10 +219,7 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/${id}/clearance/export-release`,
|
`/contracts/${id}/clearance/export-release`,
|
||||||
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
|
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
|
||||||
`/contracts/${id}/clearance/finalize-export-clearance`,
|
`/contracts/${id}/clearance/finalize-export-clearance`,
|
||||||
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
|
||||||
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
|
||||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
|
||||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||||
`/contracts/${id}/clearance/ops-review`,
|
`/contracts/${id}/clearance/ops-review`,
|
||||||
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
||||||
@@ -228,6 +227,9 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
|
BOOKINGS_INITIATE: (id: string) => `/contracts/${id}/bookings/initiate`,
|
||||||
|
// GL worklist: executed customs contracts with no shipment instance yet.
|
||||||
|
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
|
||||||
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||||
`/contracts/${id}/bookings/${bookingId}/complete`,
|
`/contracts/${id}/bookings/${bookingId}/complete`,
|
||||||
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
||||||
|
|||||||
@@ -67,8 +67,14 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Shipment in Progress",
|
label: "Shipment in Progress",
|
||||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||||
},
|
},
|
||||||
|
SUSPENDED: {
|
||||||
|
label: "Suspended",
|
||||||
|
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
},
|
||||||
CONTRACT_CLOSED: {
|
CONTRACT_CLOSED: {
|
||||||
label: "Closed",
|
// A fulfilled contract (one-time shipment delivered, or cap consumed) —
|
||||||
|
// greyed out to read as inactive.
|
||||||
|
label: "Completed",
|
||||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||||
},
|
},
|
||||||
EXPIRED: {
|
EXPIRED: {
|
||||||
@@ -122,6 +128,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
|||||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||||
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
|
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
|
||||||
|
SUSPENDED: "orange",
|
||||||
CONTRACT_CLOSED: "gray",
|
CONTRACT_CLOSED: "gray",
|
||||||
EXPIRED: "red",
|
EXPIRED: "red",
|
||||||
REJECTED: "red",
|
REJECTED: "red",
|
||||||
@@ -232,11 +239,17 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
stage: 4,
|
stage: 4,
|
||||||
},
|
},
|
||||||
CONTRACT_CLOSED: {
|
CONTRACT_CLOSED: {
|
||||||
title: "Closed",
|
title: "Completed",
|
||||||
description: "Contract fulfilled and closed.",
|
description: "Contract fulfilled — its shipment was delivered.",
|
||||||
color: "text-slate-500",
|
color: "text-slate-500",
|
||||||
stage: 5,
|
stage: 5,
|
||||||
},
|
},
|
||||||
|
SUSPENDED: {
|
||||||
|
title: "Suspended",
|
||||||
|
description: "Frozen by EDR — bookings and shipments are on hold.",
|
||||||
|
color: "text-orange-600",
|
||||||
|
stage: -1,
|
||||||
|
},
|
||||||
EXPIRED: {
|
EXPIRED: {
|
||||||
title: "Expired",
|
title: "Expired",
|
||||||
description: "Validity window elapsed.",
|
description: "Validity window elapsed.",
|
||||||
|
|||||||
@@ -53,27 +53,14 @@ export function useContractClearanceQueue(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEtClearanceQueue(enabled = true) {
|
/**
|
||||||
|
* GL worklist: executed customs contracts still waiting for their shipment
|
||||||
|
* instance to be opened (clearance itself lives on the booking).
|
||||||
|
*/
|
||||||
|
export function useAwaitingShipmentContracts(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"),
|
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("AWAITING_SHIPMENT"),
|
||||||
queryFn: () => contractsService.getEtClearanceQueue(),
|
queryFn: () => contractsService.getAwaitingShipmentContracts(),
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDjClearanceQueue(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"),
|
|
||||||
queryFn: () => contractsService.getDjClearanceQueue(),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
|
||||||
export function useOpsClearanceQueue(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
|
|
||||||
queryFn: () => contractsService.getOpsClearanceQueue(),
|
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,6 +159,19 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const suspend = useMutation({
|
||||||
|
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||||
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to suspend contract")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resume = useMutation({
|
||||||
|
mutationFn: (note: string | undefined) => contractsService.resume(contractId, note),
|
||||||
|
onSuccess: (data) =>
|
||||||
|
onSuccess(data, `Suspension lifted — contract is back to ${data.status}`),
|
||||||
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
||||||
|
});
|
||||||
|
|
||||||
const approveStep = useMutation({
|
const approveStep = useMutation({
|
||||||
// The server derives the required role from the step itself, so the client
|
// The server derives the required role from the step itself, so the client
|
||||||
// does not send one.
|
// does not send one.
|
||||||
@@ -277,6 +277,8 @@ export function useContractMutations(contractId: string) {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
reject,
|
reject,
|
||||||
|
suspend,
|
||||||
|
resume,
|
||||||
approveStep,
|
approveStep,
|
||||||
rejectStep,
|
rejectStep,
|
||||||
generateContract,
|
generateContract,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
}, [clearance]);
|
}, [clearance]);
|
||||||
|
|
||||||
const reference = booking?.reference ?? "Clearance";
|
const reference = booking?.reference ?? "Clearance";
|
||||||
|
// Phased customs clearance runs on every contract booking now — ONE_TIME and
|
||||||
|
// GENERAL alike; the persisted phase is what marks the workflow as running.
|
||||||
const isPhasedGeneral =
|
const isPhasedGeneral =
|
||||||
Boolean(booking?.customsClearingEnabled) &&
|
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
|
||||||
booking?.contractKind === "GENERAL" &&
|
|
||||||
Boolean(clearance?.phase);
|
|
||||||
|
|
||||||
// Bare initiated instance whose clearance is done: GL completes the booking
|
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||||
// (container numbers, VGM, shipment day) via the completion form.
|
// (container numbers, VGM, shipment day) via the completion form.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { directionLabel } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -6,36 +5,20 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
||||||
ArrowRight,
|
|
||||||
FileText,
|
|
||||||
Inbox,
|
|
||||||
RefreshCw,
|
|
||||||
Repeat,
|
|
||||||
Search,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import {
|
|
||||||
toContractListRow,
|
|
||||||
type ContractListRow,
|
|
||||||
} from "@/features/contracts/mapContractListRow";
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -46,45 +29,16 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operations "Clearance Documents" hub — worklist for clearance-document
|
* Operations "Clearance Documents" hub — the worklist for self-clearance
|
||||||
* review on contracts WITHOUT customs clearing (self-clearance):
|
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
|
||||||
* Contracts tab = contract-level review (one-time flow), General tab =
|
* uploads his documents on the booking he initiated, whatever kind of contract
|
||||||
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
|
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
|
||||||
* the existing review detail pages; search / status filter / pagination are
|
* clearance review page; search / status filter / pagination are server-side.
|
||||||
* all server-side.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type HubTab = "contracts" | "general";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
/** Status filter options for the Contracts tab (values = `statuses` param). */
|
/** Status filter options (values = `statuses` param). */
|
||||||
const CONTRACT_STATUS_OPTIONS = [
|
|
||||||
{
|
|
||||||
value: [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
|
||||||
"FULLY_EXECUTED",
|
|
||||||
"CONTRACT_ACTIVE",
|
|
||||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
|
||||||
"CONTRACT_CLOSED",
|
|
||||||
"CANCELLED",
|
|
||||||
].join(","),
|
|
||||||
label: "All statuses",
|
|
||||||
},
|
|
||||||
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
|
|
||||||
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
|
|
||||||
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
|
|
||||||
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
|
|
||||||
{
|
|
||||||
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
|
|
||||||
label: "In progress / closed",
|
|
||||||
},
|
|
||||||
{ value: "CANCELLED", label: "Cancelled" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Status filter options for the General (per-booking) tab. */
|
|
||||||
const BOOKING_STATUS_OPTIONS = [
|
const BOOKING_STATUS_OPTIONS = [
|
||||||
{
|
{
|
||||||
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
||||||
@@ -97,12 +51,8 @@ const BOOKING_STATUS_OPTIONS = [
|
|||||||
|
|
||||||
export default function ClearanceDocumentsPage() {
|
export default function ClearanceDocumentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
const [contractStatuses, setContractStatuses] = useState(
|
|
||||||
CONTRACT_STATUS_OPTIONS[0].value,
|
|
||||||
);
|
|
||||||
const [bookingStatuses, setBookingStatuses] = useState(
|
const [bookingStatuses, setBookingStatuses] = useState(
|
||||||
BOOKING_STATUS_OPTIONS[0].value,
|
BOOKING_STATUS_OPTIONS[0].value,
|
||||||
);
|
);
|
||||||
@@ -116,34 +66,12 @@ export default function ClearanceDocumentsPage() {
|
|||||||
|
|
||||||
const page = pagination.pageIndex + 1;
|
const page = pagination.pageIndex + 1;
|
||||||
|
|
||||||
const contractsQuery = useQuery({
|
const bookingsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
||||||
"clearance-documents",
|
|
||||||
"contracts",
|
|
||||||
contractStatuses,
|
|
||||||
page,
|
|
||||||
search,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
contractsService.getOpsClearanceQueue({
|
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||||
page,
|
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||||
pageSize: PAGE_SIZE,
|
// clearance statuses are what isolate exactly this worklist.
|
||||||
statuses: contractStatuses,
|
|
||||||
search,
|
|
||||||
}),
|
|
||||||
enabled: hubTab === "contracts",
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const generalQuery = useQuery({
|
|
||||||
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
|
||||||
queryFn: () =>
|
|
||||||
// Per-booking self-clearance instances are drawdowns under GENERAL
|
|
||||||
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
|
|
||||||
// is one-time) with contractKind=GENERAL, so filtering on
|
|
||||||
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
|
|
||||||
// =false + the three per-booking clearance statuses already isolate
|
|
||||||
// exactly this worklist — the same set the old booking-request tab showed.
|
|
||||||
bookingsService.list({
|
bookingsService.list({
|
||||||
statuses: bookingStatuses,
|
statuses: bookingStatuses,
|
||||||
customsClearingEnabled: "false",
|
customsClearingEnabled: "false",
|
||||||
@@ -151,112 +79,10 @@ export default function ClearanceDocumentsPage() {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
search,
|
search,
|
||||||
}),
|
}),
|
||||||
enabled: hubTab === "general",
|
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contractRows = useMemo(
|
const bookingRows = bookingsQuery.data?.items ?? [];
|
||||||
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
|
|
||||||
[contractsQuery.data?.items],
|
|
||||||
);
|
|
||||||
const bookingRows = generalQuery.data?.items ?? [];
|
|
||||||
|
|
||||||
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "contract",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
|
||||||
<div className={bookingTable.rowIcon}>
|
|
||||||
<User className="size-4" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-foreground">
|
|
||||||
{c.customerLabel}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
||||||
<FileText className="size-3 shrink-0 opacity-70" />
|
|
||||||
{c.reference}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "route",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original;
|
|
||||||
return (
|
|
||||||
<div className="space-y-1 py-1">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
||||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
|
||||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="max-w-[8rem] truncate">
|
|
||||||
{c.destinationLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{directionLabel(c.tradeDirection)}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
|
||||||
>
|
|
||||||
{c.freightType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kind",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
{row.original.contractKind === "GENERAL" ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Repeat className="size-3" /> General
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"One-time"
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
size: 200,
|
|
||||||
minSize: 180,
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="py-1">
|
|
||||||
<ContractStatusBadge
|
|
||||||
status={row.original.status}
|
|
||||||
isRenewal={row.original.isRenewal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
meta: {
|
|
||||||
headerClassName: "min-w-[11rem]",
|
|
||||||
cellClassName: "min-w-[11rem]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -335,39 +161,29 @@ export default function ClearanceDocumentsPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isContracts = hubTab === "contracts";
|
const total = bookingsQuery.data?.total ?? 0;
|
||||||
const activeQuery = isContracts ? contractsQuery : generalQuery;
|
|
||||||
const total = activeQuery.data?.total ?? 0;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
const showEmpty =
|
const showEmpty =
|
||||||
!activeQuery.isLoading &&
|
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
|
||||||
!activeQuery.isError &&
|
const tableStatus = bookingsQuery.isLoading
|
||||||
(isContracts ? contractRows.length : bookingRows.length) === 0;
|
|
||||||
const tableStatus = activeQuery.isLoading
|
|
||||||
? "loading"
|
? "loading"
|
||||||
: activeQuery.isError
|
: bookingsQuery.isError
|
||||||
? "error"
|
? "error"
|
||||||
: "success";
|
: "success";
|
||||||
|
|
||||||
const statusOptions = isContracts
|
|
||||||
? CONTRACT_STATUS_OPTIONS
|
|
||||||
: BOOKING_STATUS_OPTIONS;
|
|
||||||
const statusValue = isContracts ? contractStatuses : bookingStatuses;
|
|
||||||
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clearance Documents"
|
title="Clearance Documents"
|
||||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
|
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
loading={activeQuery.isFetching}
|
loading={bookingsQuery.isFetching}
|
||||||
onClick={() => void activeQuery.refetch()}
|
onClick={() => void bookingsQuery.refetch()}
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
@@ -375,29 +191,12 @@ export default function ClearanceDocumentsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={hubTab}
|
|
||||||
onChange={(v) => {
|
|
||||||
setHubTab((v as HubTab) ?? "contracts");
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={
|
placeholder="Search booking, contract or customer…"
|
||||||
isContracts
|
|
||||||
? "Search reference or customer…"
|
|
||||||
: "Search booking, contract or customer…"
|
|
||||||
}
|
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={18} />}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -424,10 +223,10 @@ export default function ClearanceDocumentsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
data={statusOptions}
|
data={BOOKING_STATUS_OPTIONS}
|
||||||
value={statusValue}
|
value={bookingStatuses}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setStatusValue(v ?? statusOptions[0].value);
|
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
|
||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
@@ -446,61 +245,30 @@ export default function ClearanceDocumentsPage() {
|
|||||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
<Inbox size={22} />
|
<Inbox size={22} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text c="dimmed">
|
<Text c="dimmed">No shipments match this view.</Text>
|
||||||
No {isContracts ? "contracts" : "bookings"} match this view.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
{isContracts ? (
|
<DataTable
|
||||||
<DataTable
|
columns={bookingColumns}
|
||||||
columns={contractColumns}
|
data={bookingRows}
|
||||||
data={contractRows}
|
status={tableStatus}
|
||||||
status={tableStatus}
|
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
|
||||||
onRowClick={(row) =>
|
pagination={{
|
||||||
navigate(
|
pageIndex: pagination.pageIndex,
|
||||||
`/dashboard/contracts/clearance-documents/${row.id}`,
|
pageSize: pagination.pageSize,
|
||||||
)
|
pageCount,
|
||||||
}
|
totalCount: total,
|
||||||
pagination={{
|
}}
|
||||||
pageIndex: pagination.pageIndex,
|
tableOptions={{
|
||||||
pageSize: pagination.pageSize,
|
state: { pagination },
|
||||||
pageCount,
|
onPaginationChange: setPagination,
|
||||||
totalCount: total,
|
manualPagination: true,
|
||||||
}}
|
pageCount,
|
||||||
tableOptions={{
|
}}
|
||||||
state: { pagination },
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
onPaginationChange: setPagination,
|
footer={DataTableFooter}
|
||||||
manualPagination: true,
|
/>
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={bookingColumns}
|
|
||||||
data={bookingRows}
|
|
||||||
status={tableStatus}
|
|
||||||
onRowClick={(row) =>
|
|
||||||
navigate(`/dashboard/clearance/${row.id}`)
|
|
||||||
}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SegmentedControl,
|
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -21,14 +20,11 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileSignature,
|
|
||||||
FileText,
|
FileText,
|
||||||
Inbox,
|
Inbox,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
|
||||||
Ship,
|
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Truck,
|
Truck,
|
||||||
User,
|
User,
|
||||||
@@ -41,18 +37,14 @@ import {
|
|||||||
usePagination,
|
usePagination,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
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 { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
|
||||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
type QueueTab = "contracts" | "shipments";
|
|
||||||
|
|
||||||
const prettyStatus = (s?: string | null) =>
|
const prettyStatus = (s?: string | null) =>
|
||||||
(s ?? "")
|
(s ?? "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -198,52 +190,6 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContractRow {
|
|
||||||
id: string;
|
|
||||||
reference: string;
|
|
||||||
customerLabel: string;
|
|
||||||
originLabel: string;
|
|
||||||
destinationLabel: string;
|
|
||||||
tradeDirection: string;
|
|
||||||
freightType: string;
|
|
||||||
serviceTypeName: string;
|
|
||||||
customs: boolean;
|
|
||||||
status: string;
|
|
||||||
clearanceStatus: string;
|
|
||||||
phase: string | null;
|
|
||||||
cycleNumber: number;
|
|
||||||
validFrom: string | null;
|
|
||||||
validUntil: string | null;
|
|
||||||
validityDays: number | null;
|
|
||||||
estimatedShipmentDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toContractRow(c: Freight.IContract): ContractRow {
|
|
||||||
const routes = [...(c.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
||||||
const first = routes[0];
|
|
||||||
const last = routes[routes.length - 1] ?? first;
|
|
||||||
return {
|
|
||||||
id: c.id,
|
|
||||||
reference: c.reference,
|
|
||||||
customerLabel: c.isGovernment
|
|
||||||
? (c.governmentInstitution ?? "Government")
|
|
||||||
: (c.company?.name ?? "—"),
|
|
||||||
originLabel: yardLabel(first?.originYard),
|
|
||||||
destinationLabel: yardLabel(last?.destinationYard),
|
|
||||||
tradeDirection: c.tradeDirection ?? "—",
|
|
||||||
freightType: c.freightType ?? "—",
|
|
||||||
serviceTypeName: c.serviceType?.serviceName ?? "—",
|
|
||||||
customs: c.serviceType?.includesCustoms ?? Boolean(c.customsClearingEnabled),
|
|
||||||
status: c.status,
|
|
||||||
clearanceStatus: c.clearanceStatus,
|
|
||||||
phase: (c.clearancePhase as string | null) ?? null,
|
|
||||||
cycleNumber: c.clearanceCycleNumber ?? 1,
|
|
||||||
validFrom: c.contractValidFrom ?? null,
|
|
||||||
validUntil: c.contractValidUntil ?? null,
|
|
||||||
validityDays: c.contractValidityDays ?? null,
|
|
||||||
estimatedShipmentDate: c.estimatedShipmentDate ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -301,15 +247,13 @@ function RouteCell({
|
|||||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL Djibouti clearance queues:
|
* GL Djibouti clearance queue. Clearance runs per SHIPMENT — every booking in
|
||||||
* - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow).
|
* per-booking clearance awaiting a Djibouti action (DO collection after ET
|
||||||
* - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ
|
* finalizes pre-clearance, RO for exports, loading milestones), whatever kind
|
||||||
* action (DO collection after ET finalizes pre-clearance, RO for exports,
|
* of contract it draws on.
|
||||||
* loading milestones). Managed like the one-time flow, but per booking.
|
|
||||||
*/
|
*/
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<QueueTab>("shipments");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [direction, setDirection] = useState<string | null>(null);
|
const [direction, setDirection] = useState<string | null>(null);
|
||||||
const [freight, setFreight] = useState<string | null>(null);
|
const [freight, setFreight] = useState<string | null>(null);
|
||||||
@@ -317,13 +261,6 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
const [action, setAction] = useState<string | null>(null);
|
const [action, setAction] = useState<string | null>(null);
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const {
|
|
||||||
data: contractQueue,
|
|
||||||
isLoading: contractsLoading,
|
|
||||||
isError: contractsError,
|
|
||||||
isFetching: contractsFetching,
|
|
||||||
refetch: refetchContracts,
|
|
||||||
} = useDjClearanceQueue();
|
|
||||||
const {
|
const {
|
||||||
data: bookingQueue,
|
data: bookingQueue,
|
||||||
isLoading: bookingsLoading,
|
isLoading: bookingsLoading,
|
||||||
@@ -341,34 +278,26 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||||
[bookingQueue],
|
[bookingQueue],
|
||||||
);
|
);
|
||||||
const allContractRows = useMemo(
|
// KPI metrics span the whole queue, regardless of filters.
|
||||||
() => (contractQueue?.items ?? []).map(toContractRow),
|
|
||||||
[contractQueue?.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
// KPI metrics span both queues, regardless of active tab or filters.
|
|
||||||
const metrics = useMemo(
|
const metrics = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
shipments: allShipmentRows.length,
|
shipments: allShipmentRows.length,
|
||||||
contracts: allContractRows.length,
|
|
||||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
||||||
.length,
|
.length,
|
||||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
||||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
||||||
}),
|
}),
|
||||||
[allShipmentRows, allContractRows],
|
[allShipmentRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusOptions = useMemo(() => {
|
const statusOptions = useMemo(
|
||||||
const source =
|
() =>
|
||||||
tab === "shipments"
|
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||||
? allShipmentRows.map((r) => r.status)
|
value: s,
|
||||||
: allContractRows.map((r) => r.status);
|
label: prettyStatus(s),
|
||||||
return [...new Set(source)].sort().map((s) => ({
|
})),
|
||||||
value: s,
|
[allShipmentRows],
|
||||||
label: prettyStatus(s),
|
);
|
||||||
}));
|
|
||||||
}, [tab, allShipmentRows, allContractRows]);
|
|
||||||
|
|
||||||
const matchesShared = useCallback(
|
const matchesShared = useCallback(
|
||||||
(
|
(
|
||||||
@@ -411,16 +340,10 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
[allShipmentRows, action, matchesShared],
|
[allShipmentRows, action, matchesShared],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contractRows = useMemo(
|
const isLoading = bookingsLoading;
|
||||||
() => allContractRows.filter((r) => matchesShared(r)),
|
const isError = bookingsError;
|
||||||
[allContractRows, matchesShared],
|
const isFetching = bookingsFetching;
|
||||||
);
|
const total = shipmentRows.length;
|
||||||
|
|
||||||
const rows = tab === "shipments" ? shipmentRows : contractRows;
|
|
||||||
const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading;
|
|
||||||
const isError = tab === "contracts" ? contractsError : bookingsError;
|
|
||||||
const isFetching = contractsFetching || bookingsFetching;
|
|
||||||
const total = rows.length;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
const showEmpty = !isLoading && !isError && total === 0;
|
const showEmpty = !isLoading && !isError && total === 0;
|
||||||
|
|
||||||
@@ -429,11 +352,6 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
const pagedContractRows = useMemo(() => {
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
return contractRows.slice(start, start + pagination.pageSize);
|
|
||||||
}, [contractRows, pagination.pageIndex, pagination.pageSize]);
|
|
||||||
|
|
||||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
const hasFilters = Boolean(query || direction || freight || status || action);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
@@ -446,9 +364,8 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
}, [resetPage]);
|
}, [resetPage]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetchContracts();
|
|
||||||
void refetchBookings();
|
void refetchBookings();
|
||||||
}, [refetchContracts, refetchBookings]);
|
}, [refetchBookings]);
|
||||||
|
|
||||||
const openDetail = useCallback(
|
const openDetail = useCallback(
|
||||||
(id: string) => navigate(`/dashboard/gl-djibouti/clearance/${id}`),
|
(id: string) => navigate(`/dashboard/gl-djibouti/clearance/${id}`),
|
||||||
@@ -599,161 +516,12 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contractColumns: ColumnDef<ContractRow>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "contract",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
|
||||||
<div className={bookingTable.rowIcon}>
|
|
||||||
<Ship className="size-4" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-foreground">
|
|
||||||
{r.reference}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
|
||||||
{r.customerLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "route",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original;
|
|
||||||
return (
|
|
||||||
<RouteCell
|
|
||||||
origin={r.originLabel}
|
|
||||||
destination={r.destinationLabel}
|
|
||||||
direction={r.tradeDirection}
|
|
||||||
freightType={r.freightType}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "service",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Service</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original;
|
|
||||||
return (
|
|
||||||
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
|
|
||||||
<Text size="sm" fw={500} truncate maw={160}>
|
|
||||||
{r.serviceTypeName}
|
|
||||||
</Text>
|
|
||||||
{r.customs ? (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<ShieldCheck size={11} />}
|
|
||||||
>
|
|
||||||
Customs
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
|
||||||
No customs
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "clearance",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Clearance</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original;
|
|
||||||
return (
|
|
||||||
<Stack gap={4} py={2}>
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={statusColor(r.clearanceStatus)}
|
|
||||||
radius="sm"
|
|
||||||
>
|
|
||||||
{prettyStatus(r.clearanceStatus)}
|
|
||||||
</Badge>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{phaseLabel(r.phase)}
|
|
||||||
{r.cycleNumber > 1 ? ` · Cycle ${r.cycleNumber}` : ""}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
color={statusColor(row.original.status)}
|
|
||||||
radius="sm"
|
|
||||||
>
|
|
||||||
{prettyStatus(row.original.status)}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "validity",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original;
|
|
||||||
return (
|
|
||||||
<Stack gap={2} py={2}>
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<CalendarClock
|
|
||||||
size={13}
|
|
||||||
className="shrink-0 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{r.validUntil
|
|
||||||
? `Until ${formatDate(r.validUntil)}`
|
|
||||||
: r.validityDays
|
|
||||||
? `${r.validityDays} days`
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{r.estimatedShipmentDate ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Est. shipment {formatDate(r.estimatedShipmentDate)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chevron",
|
|
||||||
size: 40,
|
|
||||||
header: "",
|
|
||||||
cell: () => (
|
|
||||||
<Group justify="flex-end" pr="xs">
|
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
title="GL Djibouti — Clearance"
|
||||||
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -769,7 +537,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
loading={contractsLoading || bookingsLoading}
|
loading={bookingsLoading}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Shipments in queue",
|
label: "Shipments in queue",
|
||||||
@@ -777,12 +545,6 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Contracts in queue",
|
|
||||||
value: metrics.contracts,
|
|
||||||
icon: FileSignature,
|
|
||||||
color: "edr-green",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Imports — collect DO",
|
label: "Imports — collect DO",
|
||||||
value: metrics.collectDo,
|
value: metrics.collectDo,
|
||||||
@@ -806,46 +568,6 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
|
|
||||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md">
|
|
||||||
<SegmentedControl
|
|
||||||
size="sm"
|
|
||||||
value={tab}
|
|
||||||
onChange={(v) => {
|
|
||||||
setTab(v as QueueTab);
|
|
||||||
setStatus(null);
|
|
||||||
setAction(null);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
radius="md"
|
|
||||||
data={[
|
|
||||||
{
|
|
||||||
value: "shipments",
|
|
||||||
label: (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<PackageCheck size={15} />
|
|
||||||
<Box visibleFrom="sm">Shipments</Box>
|
|
||||||
<Badge size="sm" radius="sm" variant="light" color="edr-green">
|
|
||||||
{allShipmentRows.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "contracts",
|
|
||||||
label: (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<FileSignature size={15} />
|
|
||||||
<Box visibleFrom="sm">Contracts</Box>
|
|
||||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
|
||||||
{allContractRows.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box px="md" pt="md" pb="sm">
|
<Box px="md" pt="md" pb="sm">
|
||||||
<Group gap="sm" wrap="wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -917,20 +639,18 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
w={190}
|
w={190}
|
||||||
/>
|
/>
|
||||||
{tab === "shipments" ? (
|
<Select
|
||||||
<Select
|
placeholder="DJ action"
|
||||||
placeholder="DJ action"
|
data={DJ_ACTION_OPTIONS}
|
||||||
data={DJ_ACTION_OPTIONS}
|
value={action}
|
||||||
value={action}
|
onChange={(v) => {
|
||||||
onChange={(v) => {
|
setAction(v);
|
||||||
setAction(v);
|
resetPage();
|
||||||
resetPage();
|
}}
|
||||||
}}
|
clearable
|
||||||
clearable
|
radius="lg"
|
||||||
radius="lg"
|
w={180}
|
||||||
w={180}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{hasFilters ? (
|
{hasFilters ? (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -957,9 +677,7 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
<Text c="dimmed">
|
<Text c="dimmed">
|
||||||
{hasFilters
|
{hasFilters
|
||||||
? "No records match these filters."
|
? "No records match these filters."
|
||||||
: tab === "contracts"
|
: "No shipments awaiting a Djibouti action."}
|
||||||
? "No Djibouti customs contracts yet."
|
|
||||||
: "No shipment bookings awaiting a Djibouti action."}
|
|
||||||
</Text>
|
</Text>
|
||||||
{hasFilters ? (
|
{hasFilters ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -975,49 +693,26 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
{tab === "shipments" ? (
|
<DataTable<ShipmentRow, unknown>
|
||||||
<DataTable<ShipmentRow, unknown>
|
columns={shipmentColumns}
|
||||||
columns={shipmentColumns}
|
data={pagedShipmentRows}
|
||||||
data={pagedShipmentRows}
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
onRowClick={(row) => openDetail(row.id)}
|
||||||
onRowClick={(row) => openDetail(row.id)}
|
pagination={{
|
||||||
pagination={{
|
pageIndex: pagination.pageIndex,
|
||||||
pageIndex: pagination.pageIndex,
|
pageSize: pagination.pageSize,
|
||||||
pageSize: pagination.pageSize,
|
pageCount,
|
||||||
pageCount,
|
totalCount: total,
|
||||||
totalCount: total,
|
}}
|
||||||
}}
|
tableOptions={{
|
||||||
tableOptions={{
|
state: { pagination },
|
||||||
state: { pagination },
|
onPaginationChange: setPagination,
|
||||||
onPaginationChange: setPagination,
|
manualPagination: true,
|
||||||
manualPagination: true,
|
pageCount,
|
||||||
pageCount,
|
}}
|
||||||
}}
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
footer={DataTableFooter}
|
||||||
footer={DataTableFooter}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DataTable<ContractRow, unknown>
|
|
||||||
columns={contractColumns}
|
|
||||||
data={pagedContractRows}
|
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
||||||
onRowClick={(row) => openDetail(row.id)}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Train as TrainIcon,
|
Train as TrainIcon,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Weight,
|
Weight,
|
||||||
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -48,6 +49,7 @@ import { api } from "@/services/api";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||||
|
|
||||||
const parseError = (error: unknown, fallback: string) => {
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
@@ -78,6 +80,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||||
|
const [maintenanceTarget, setMaintenanceTarget] =
|
||||||
|
useState<TrainCompositionWagon | null>(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canUpdate = canFleetAction(user, "trains", "update");
|
const canUpdate = canFleetAction(user, "trains", "update");
|
||||||
const canDelete = canFleetAction(user, "trains", "delete");
|
const canDelete = canFleetAction(user, "trains", "delete");
|
||||||
@@ -396,12 +400,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
"Could not detach wagon",
|
"Could not detach wagon",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onMaintenance={(wagonId) =>
|
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||||
void withToast(
|
|
||||||
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
|
|
||||||
"Could not send wagon to maintenance",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -460,6 +459,54 @@ export default function TrainBuilderDetailPage() {
|
|||||||
onClose={() => setYardModalOpen(false)}
|
onClose={() => setYardModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(maintenanceTarget)}
|
||||||
|
onClose={() => setMaintenanceTarget(null)}
|
||||||
|
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Wagon{" "}
|
||||||
|
<Text span fw={700} ff="monospace" c="dark">
|
||||||
|
{maintenanceTarget?.wagonNumber}
|
||||||
|
</Text>{" "}
|
||||||
|
is detached from train{" "}
|
||||||
|
<Text span fw={700} c="dark">
|
||||||
|
{composition.code}
|
||||||
|
</Text>{" "}
|
||||||
|
and set to MAINTENANCE — it stays out of the available pool until it
|
||||||
|
clears. The detach is stamped with the time and this train number in
|
||||||
|
the wagon's history.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
|
||||||
|
Keep in consist
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
leftSection={<Wrench size={16} />}
|
||||||
|
loading={maintenanceWagon.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void withToast(async () => {
|
||||||
|
await maintenanceWagon.mutateAsync({
|
||||||
|
id: composition.id,
|
||||||
|
wagonId: maintenanceTarget!.id,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||||
|
});
|
||||||
|
setMaintenanceTarget(null);
|
||||||
|
}, "Could not send wagon to maintenance")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Send to maintenance
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={deactivateOpen}
|
opened={deactivateOpen}
|
||||||
onClose={() => setDeactivateOpen(false)}
|
onClose={() => setDeactivateOpen(false)}
|
||||||
|
|||||||
@@ -245,6 +245,14 @@ export const contractsService = {
|
|||||||
reject: (id: string, reason: string) =>
|
reject: (id: string, reason: string) =>
|
||||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||||
|
|
||||||
|
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
||||||
|
suspend: (id: string, reason: string) =>
|
||||||
|
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
||||||
|
|
||||||
|
/** Lift a suspension; the contract returns to the status it was frozen at. */
|
||||||
|
resume: (id: string, note?: string) =>
|
||||||
|
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approve the next pending step. The server resolves the step's required role
|
* Approve the next pending step. The server resolves the step's required role
|
||||||
* and authorizes against it — the client never declares its own role.
|
* and authorizes against it — the client never declares its own role.
|
||||||
@@ -377,22 +385,29 @@ export const contractsService = {
|
|||||||
finalizeClearance: (id: string) =>
|
finalizeClearance: (id: string) =>
|
||||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||||
|
|
||||||
getEtClearanceQueue: async (): Promise<PaginatedContracts> => {
|
/**
|
||||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_ET_QUEUE);
|
* GL worklist: executed one-time customs contracts with no shipment instance
|
||||||
|
* yet. GL initiates the booking; the customer then uploads his clearance
|
||||||
|
* documents on it.
|
||||||
|
*/
|
||||||
|
getAwaitingShipmentContracts: async (): Promise<Freight.IContract[]> => {
|
||||||
|
const response = await client.get(C.AWAITING_SHIPMENT);
|
||||||
const data = unwrap(response.data);
|
const data = unwrap(response.data);
|
||||||
return {
|
return (Array.isArray(data) ? data : (data?.items ?? [])) as Freight.IContract[];
|
||||||
items: (data.items ?? []) as Freight.IContract[],
|
|
||||||
total: data.total ?? 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getDjClearanceQueue: async (): Promise<PaginatedContracts> => {
|
/** Open a bare shipment instance under a contract (no cargo, no day). */
|
||||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_DJ_QUEUE);
|
initiateBookingUnderContract: async (
|
||||||
const data = unwrap(response.data);
|
id: string,
|
||||||
return {
|
contractRouteId?: string,
|
||||||
items: (data.items ?? []) as Freight.IContract[],
|
): Promise<{ id: string; reference: string }> => {
|
||||||
total: data.total ?? 0,
|
const result = await postContract<{
|
||||||
};
|
booking?: { id: string; reference: string };
|
||||||
|
id?: string;
|
||||||
|
reference?: string;
|
||||||
|
}>(C.BOOKINGS_INITIATE(id), contractRouteId ? { contractRouteId } : {});
|
||||||
|
const booking = result.booking ?? result;
|
||||||
|
return { id: booking.id ?? "", reference: booking.reference ?? "" };
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadDeclaration: async (
|
uploadDeclaration: async (
|
||||||
@@ -575,25 +590,6 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as { advised: boolean; skipped: boolean };
|
return unwrap(response.data) as { advised: boolean; skipped: boolean };
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Path A self-clearance (Operations review) ──
|
|
||||||
getOpsClearanceQueue: async (filter?: {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
search?: string;
|
|
||||||
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
|
|
||||||
statuses?: string;
|
|
||||||
}): Promise<PaginatedContracts> => {
|
|
||||||
const response = await client.get<PaginatedContracts>(
|
|
||||||
C.OPS_CLEARANCE_QUEUE,
|
|
||||||
{ params: filter },
|
|
||||||
);
|
|
||||||
const data = unwrap(response.data);
|
|
||||||
return {
|
|
||||||
items: (data.items ?? []) as Freight.IContract[],
|
|
||||||
total: data.total ?? 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getClearanceHistory: async (): Promise<PaginatedContracts> => {
|
getClearanceHistory: async (): Promise<PaginatedContracts> => {
|
||||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
|
||||||
const data = unwrap(response.data);
|
const data = unwrap(response.data);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
|||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
|
|
||||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractsList from "./pages/contracts/ContractsList";
|
import ContractsList from "./pages/contracts/ContractsList";
|
||||||
@@ -336,10 +335,6 @@ const App = () => {
|
|||||||
path="/contracts/:id/bookings/:bookingId/complete"
|
path="/contracts/:id/bookings/:bookingId/complete"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/contracts/:id/clearance"
|
|
||||||
element={<ContractClearanceFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/view"
|
path="/contracts/:id/view"
|
||||||
element={<ContractViewPage />}
|
element={<ContractViewPage />}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
|
|
||||||
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
|
||||||
import { ModalSafeWrapper } from "./ModalSafeWrapper";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
|
|
||||||
interface ContractClearanceActionProps {
|
|
||||||
contractId: string;
|
|
||||||
label?: string;
|
|
||||||
size?: ButtonProps["size"];
|
|
||||||
urgent?: boolean;
|
|
||||||
/** GL's turn — render as a calm status button, not a call to action. */
|
|
||||||
waiting?: boolean;
|
|
||||||
/** Icon override from the phase-aware action derivation. */
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractClearanceAction({
|
|
||||||
contractId,
|
|
||||||
label: labelProp,
|
|
||||||
size = "xs",
|
|
||||||
urgent = false,
|
|
||||||
waiting = false,
|
|
||||||
icon: iconProp,
|
|
||||||
}: ContractClearanceActionProps) {
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
|
|
||||||
const { data: clearance } = useQuery({
|
|
||||||
...api.contracts.getClearance.queryOptions({ input: { id: contractId } }),
|
|
||||||
enabled: opened,
|
|
||||||
});
|
|
||||||
|
|
||||||
const label = useMemo(() => {
|
|
||||||
if (labelProp) return labelProp;
|
|
||||||
const docs = clearance?.documents ?? [];
|
|
||||||
const queried = docs.filter(
|
|
||||||
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
|
||||||
).length;
|
|
||||||
if (queried > 0) return "Update clearance";
|
|
||||||
return urgent ? "Upload clearance" : "Manage clearance";
|
|
||||||
}, [labelProp, clearance, urgent]);
|
|
||||||
|
|
||||||
const Icon =
|
|
||||||
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
|
|
||||||
|
|
||||||
// Urgent (customer's turn) = filled orange so it stands out among the green
|
|
||||||
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
|
|
||||||
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
|
|
||||||
const variant = waiting ? "light" : "filled";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalSafeWrapper>
|
|
||||||
<Button
|
|
||||||
size={size}
|
|
||||||
radius="md"
|
|
||||||
fw={700}
|
|
||||||
fz={13}
|
|
||||||
color={color}
|
|
||||||
variant={variant}
|
|
||||||
leftSection={<Icon size={14} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
open();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={close}
|
|
||||||
title={
|
|
||||||
<Text fw={700} fz={16}>
|
|
||||||
Clearance documents
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
size="xl"
|
|
||||||
radius="md"
|
|
||||||
centered
|
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
||||||
>
|
|
||||||
<ContractClearancePanel contractId={contractId} bare onSubmitted={close} />
|
|
||||||
</Modal>
|
|
||||||
</ModalSafeWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ContractClearanceAction } from "./ContractClearanceAction";
|
|
||||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
|
|
||||||
interface ContractCustomerActionProps {
|
interface ContractCustomerActionProps {
|
||||||
@@ -50,19 +49,6 @@ export function ContractCustomerAction({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (action.type === "clearance") {
|
|
||||||
return (
|
|
||||||
<ContractClearanceAction
|
|
||||||
contractId={action.contractId}
|
|
||||||
label={action.label}
|
|
||||||
size={size}
|
|
||||||
urgent={action.urgent}
|
|
||||||
waiting={action.waiting}
|
|
||||||
icon={action.icon}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "pay") {
|
if (action.type === "pay") {
|
||||||
return (
|
return (
|
||||||
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
<PayNowButton booking={action.booking} label={action.label} size={size} />
|
||||||
@@ -106,9 +92,9 @@ export function ContractCustomerAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-click bare booking instance under a GENERAL non-customs contract. No
|
* One-click bare booking instance under a self-clearance import/export contract
|
||||||
* form, no date, no window gate — the new instance lands in per-booking
|
* — ONE_TIME or GENERAL. No form, no date, no window gate: the new instance
|
||||||
* clearance (AWAITING_DOCUMENTS) and the customer is taken straight to it.
|
* lands in per-booking clearance (AWAITING_DOCUMENTS) with the customer on it.
|
||||||
*/
|
*/
|
||||||
export function InitiateBookingButton({
|
export function InitiateBookingButton({
|
||||||
contract,
|
contract,
|
||||||
|
|||||||
@@ -4,43 +4,13 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Eye,
|
Eye,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Hourglass,
|
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Receipt,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
||||||
|
|
||||||
const CLEARANCE_IN_PROGRESS_STATUSES = [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function contractNeedsClearanceAction(c: Freight.IContract): {
|
|
||||||
show: boolean;
|
|
||||||
urgent: boolean;
|
|
||||||
} {
|
|
||||||
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
|
|
||||||
const ready =
|
|
||||||
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
clearance === "SELF_CLEARED" ||
|
|
||||||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
if (ready) return { show: false, urgent: false };
|
|
||||||
|
|
||||||
const awaiting =
|
|
||||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
|
||||||
clearance === "AWAITING_DOCUMENTS";
|
|
||||||
const inProgress =
|
|
||||||
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
|
|
||||||
clearance === "AWAITING_DOCUMENTS" ||
|
|
||||||
clearance === "DOCUMENTS_UNDER_REVIEW";
|
|
||||||
|
|
||||||
return { show: inProgress, urgent: awaiting };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContractCustomerAction =
|
export type ContractCustomerAction =
|
||||||
| {
|
| {
|
||||||
type: "sign";
|
type: "sign";
|
||||||
@@ -56,16 +26,6 @@ export type ContractCustomerAction =
|
|||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "clearance";
|
|
||||||
contractId: string;
|
|
||||||
label: string;
|
|
||||||
primary: boolean;
|
|
||||||
icon: LucideIcon;
|
|
||||||
urgent: boolean;
|
|
||||||
/** True when it's GL's turn — render calm/informational, not a call to action. */
|
|
||||||
waiting?: boolean;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "pay";
|
type: "pay";
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
@@ -148,60 +108,6 @@ export function deriveContractCustomerAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const clr = contractNeedsClearanceAction(contract);
|
|
||||||
if (clr.show) {
|
|
||||||
// Refine the generic clearance action by the persisted clearance phase so
|
|
||||||
// the button says what the customer actually has to do right now (e.g.
|
|
||||||
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
|
|
||||||
const phase = contract.clearancePhase ?? null;
|
|
||||||
switch (phase) {
|
|
||||||
case "CUSTOMER_INTAKE":
|
|
||||||
return {
|
|
||||||
type: "clearance",
|
|
||||||
contractId: id,
|
|
||||||
label: "Upload clearance documents",
|
|
||||||
primary: true,
|
|
||||||
icon: Upload,
|
|
||||||
urgent: true,
|
|
||||||
};
|
|
||||||
case "CUSTOMER_DUTY":
|
|
||||||
return {
|
|
||||||
type: "clearance",
|
|
||||||
contractId: id,
|
|
||||||
label: "Pay duty & upload slip",
|
|
||||||
primary: true,
|
|
||||||
icon: Receipt,
|
|
||||||
urgent: true,
|
|
||||||
};
|
|
||||||
case "GL_ET_REVIEW":
|
|
||||||
case "GL_DJ_COLLECTION":
|
|
||||||
case "GL_ET_OUTPUT":
|
|
||||||
case "GL_ET_POST_CLEARANCE":
|
|
||||||
case "GL_DJ_LOADING":
|
|
||||||
case "POST_TRANSIT":
|
|
||||||
// GL's turn — nothing for the customer to do; show a calm status.
|
|
||||||
return {
|
|
||||||
type: "clearance",
|
|
||||||
contractId: id,
|
|
||||||
label: "Clearance in progress",
|
|
||||||
primary: false,
|
|
||||||
icon: Hourglass,
|
|
||||||
urgent: false,
|
|
||||||
waiting: true,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
// No persisted phase (legacy / early cycles) — keep the status-derived label.
|
|
||||||
return {
|
|
||||||
type: "clearance",
|
|
||||||
contractId: id,
|
|
||||||
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
|
||||||
primary: true,
|
|
||||||
icon: Upload,
|
|
||||||
urgent: clr.urgent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contract.customsClearingEnabled &&
|
contract.customsClearingEnabled &&
|
||||||
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
||||||
`/api/contracts/${id}/contract/send-signing-otp`,
|
`/api/contracts/${id}/contract/send-signing-otp`,
|
||||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||||
|
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
|
||||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { contractNeedsClearanceAction } from "@/components/customer-actions/deriveContractCustomerAction";
|
|
||||||
|
|
||||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
id: string;
|
id: string;
|
||||||
/** What the customer must do — drives the icon, label and modal. */
|
/** What the customer must do — drives the icon, label and modal. */
|
||||||
kind: "clearance" | "duty" | "sign" | "book" | "pay";
|
kind: "sign" | "book" | "pay";
|
||||||
/** The contract/booking reference for display. */
|
/** The contract/booking reference for display. */
|
||||||
reference: string;
|
reference: string;
|
||||||
/** Short human description of the action. */
|
/** Short human description of the action. */
|
||||||
description: string;
|
description: string;
|
||||||
/** Contract id (clearance/sign/book) or booking id (pay). */
|
/** Contract id (sign/book) or booking id (pay). */
|
||||||
targetId: string;
|
targetId: string;
|
||||||
/** True for queried clearance (a document was sent back for correction). */
|
/** Highlighted as action-required in the home card. */
|
||||||
urgent?: boolean;
|
urgent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the list of pending customer actions from the customer's contracts and
|
* Derive the list of pending customer actions from the customer's contracts and
|
||||||
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
|
* bookings. Clearance never appears here any more: documents live on the
|
||||||
* re-upload after a query) is flagged urgent so the home card shows an upload
|
* shipment booking, so a pending upload surfaces as a booking action.
|
||||||
* button. See {@link contractNeedsClearanceAction}.
|
|
||||||
*/
|
*/
|
||||||
export function deriveActionItems(
|
export function deriveActionItems(
|
||||||
contracts: Freight.IContract[],
|
contracts: Freight.IContract[],
|
||||||
@@ -40,20 +37,6 @@ export function deriveActionItems(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const clr = contractNeedsClearanceAction(c);
|
|
||||||
if (clr.show) {
|
|
||||||
items.push({
|
|
||||||
id: `clearance-${c.id}`,
|
|
||||||
kind: "clearance",
|
|
||||||
reference: c.reference,
|
|
||||||
description: clr.urgent
|
|
||||||
? "Clearance documents need your action"
|
|
||||||
: "Clearance under review",
|
|
||||||
targetId: c.id,
|
|
||||||
urgent: clr.urgent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Path A transport-only: customer may create the shipment booking.
|
// Path A transport-only: customer may create the shipment booking.
|
||||||
if (
|
if (
|
||||||
!c.customsClearingEnabled &&
|
!c.customsClearingEnabled &&
|
||||||
|
|||||||
@@ -1,140 +1,53 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQueries, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
Receipt,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
import type { ActionItem } from "../actions";
|
import type { ActionItem } from "../actions";
|
||||||
|
|
||||||
// Contracts whose clearance is still in progress — candidates for a real
|
|
||||||
// per-document query check (small set; only contracts awaiting/under review).
|
|
||||||
const CLEARANCE_CANDIDATE_STATUSES = [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
];
|
|
||||||
|
|
||||||
const KIND_META: Record<
|
const KIND_META: Record<
|
||||||
ActionItem["kind"],
|
ActionItem["kind"],
|
||||||
{ icon: typeof Upload; label: string; color: string }
|
{ icon: typeof CreditCard; label: string; color: string }
|
||||||
> = {
|
> = {
|
||||||
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
|
||||||
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
|
|
||||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ActionNeededSectionProps {
|
export interface ActionNeededSectionProps {
|
||||||
/** Non-clearance actions (sign / book / pay) derived from status. */
|
/** Pending actions (sign / book / pay) derived from status. */
|
||||||
items: ActionItem[];
|
items: ActionItem[];
|
||||||
/** All of the customer's contracts — used to detect real clearance queries. */
|
|
||||||
contracts: Freight.IContract[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home "needs your attention" card. Lists pending customer actions across
|
* Home "needs your attention" card. Lists pending customer actions across
|
||||||
* contracts and bookings. Clearance items are derived from the ACTUAL clearance
|
* contracts and bookings: payment opens a modal right here; sign and book
|
||||||
* documents (so an open query always surfaces an upload button), payment + the
|
* navigate. Clearance is a per-booking action and lives on the booking page.
|
||||||
* clearance upload open in a modal right here; sign and book navigate.
|
|
||||||
*/
|
*/
|
||||||
export function ActionNeededSection({
|
export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||||
items: baseItems,
|
|
||||||
contracts,
|
|
||||||
}: ActionNeededSectionProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
|
||||||
const [payItem, setPayItem] = useState<ActionItem | null>(null);
|
const [payItem, setPayItem] = useState<ActionItem | null>(null);
|
||||||
|
|
||||||
// Fetch the clearance view for every contract still in a clearance phase, so we
|
// Urgent first; clearance never appears here — those documents live on the
|
||||||
// can detect a queried document precisely (status alone can be ambiguous).
|
// shipment booking, and its own detail page drives that upload.
|
||||||
const candidates = useMemo(
|
const items = [...base].sort(
|
||||||
() =>
|
(a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0),
|
||||||
contracts.filter((c) =>
|
|
||||||
CLEARANCE_CANDIDATE_STATUSES.includes(c.status),
|
|
||||||
),
|
|
||||||
[contracts],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearanceQueries = useQueries({
|
|
||||||
queries: candidates.map((c) =>
|
|
||||||
api.contracts.getClearance.queryOptions({ input: { id: c.id } }),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build clearance action items from the fetched views: show whenever the
|
|
||||||
// customer can still upload (not yet ready for booking), flag urgent + show the
|
|
||||||
// query count when any document was sent back for correction.
|
|
||||||
const clearanceItems = useMemo<ActionItem[]>(() => {
|
|
||||||
const out: ActionItem[] = [];
|
|
||||||
candidates.forEach((c, i) => {
|
|
||||||
const view = clearanceQueries[i]?.data;
|
|
||||||
const docs = view?.documents ?? [];
|
|
||||||
const customerDocs = docs.filter((d) => d.uploadedBy === "customer");
|
|
||||||
const queried = customerDocs.filter(
|
|
||||||
(d) => d.reviewStatus === "QUERIED",
|
|
||||||
).length;
|
|
||||||
const ready =
|
|
||||||
view?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
view?.clearanceStatus === "SELF_CLEARED" ||
|
|
||||||
view?.clearanceStatus === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
if (ready) return;
|
|
||||||
const awaiting =
|
|
||||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
|
||||||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
|
|
||||||
// Duty phase: the customer's task is paying duty/tax and uploading the
|
|
||||||
// slip — a distinct, money action, not a generic document upload.
|
|
||||||
if (view?.phase === "CUSTOMER_DUTY") {
|
|
||||||
out.push({
|
|
||||||
id: `duty-${c.id}`,
|
|
||||||
kind: "duty",
|
|
||||||
reference: c.reference,
|
|
||||||
description: "Duty / tax payment due — pay and upload the slip",
|
|
||||||
targetId: c.id,
|
|
||||||
urgent: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only surface when there's something the customer can do: a query, or the
|
|
||||||
// contract is awaiting their (re)upload.
|
|
||||||
if (queried === 0 && !awaiting) return;
|
|
||||||
out.push({
|
|
||||||
id: `clearance-${c.id}`,
|
|
||||||
kind: "clearance",
|
|
||||||
reference: c.reference,
|
|
||||||
description:
|
|
||||||
queried > 0
|
|
||||||
? `${queried} document${queried > 1 ? "s" : ""} need correction`
|
|
||||||
: "Clearance documents needed",
|
|
||||||
targetId: c.id,
|
|
||||||
urgent: queried > 0 || awaiting,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}, [candidates, clearanceQueries]);
|
|
||||||
|
|
||||||
// Merge: clearance items (from real docs) + the status-derived sign/book/pay.
|
|
||||||
const items = useMemo(
|
|
||||||
() => [...clearanceItems, ...baseItems.filter((i) => i.kind !== "clearance")],
|
|
||||||
[clearanceItems, baseItems],
|
|
||||||
).sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
|
|
||||||
|
|
||||||
// Billing is invoice-centric — resolve the booking's currently payable
|
// Billing is invoice-centric — resolve the booking's currently payable
|
||||||
// invoice before paying it (mirrors ReadonlyBookingView).
|
// invoice before paying it (mirrors ReadonlyBookingView).
|
||||||
const { data: payItemInvoices = [] } = useQuery({
|
const { data: payItemInvoices = [] } = useQuery({
|
||||||
@@ -175,13 +88,6 @@ export function ActionNeededSection({
|
|||||||
|
|
||||||
const handleClick = (item: ActionItem) => {
|
const handleClick = (item: ActionItem) => {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "clearance":
|
|
||||||
setClearanceId(item.targetId);
|
|
||||||
break;
|
|
||||||
case "duty":
|
|
||||||
// Duty advice + payment-slip upload live on the contract detail page.
|
|
||||||
navigate(`/contracts/${item.targetId}`);
|
|
||||||
break;
|
|
||||||
case "pay":
|
case "pay":
|
||||||
setPayItem(item);
|
setPayItem(item);
|
||||||
break;
|
break;
|
||||||
@@ -266,27 +172,13 @@ export function ActionNeededSection({
|
|||||||
variant={item.urgent ? "filled" : "light"}
|
variant={item.urgent ? "filled" : "light"}
|
||||||
color={meta.color}
|
color={meta.color}
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={
|
leftSection={<FilePlus2 size={14} />}
|
||||||
item.kind === "clearance" ? (
|
|
||||||
<Upload size={14} />
|
|
||||||
) : item.kind === "duty" ? (
|
|
||||||
<Receipt size={14} />
|
|
||||||
) : (
|
|
||||||
<FilePlus2 size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{item.kind === "pay"
|
{item.kind === "pay"
|
||||||
? "Pay now"
|
? "Pay now"
|
||||||
: item.kind === "duty"
|
: item.kind === "sign"
|
||||||
? "Pay duty & upload slip"
|
? "Sign"
|
||||||
: item.kind === "sign"
|
: "Book"}
|
||||||
? "Sign"
|
|
||||||
: item.kind === "book"
|
|
||||||
? "Book"
|
|
||||||
: item.urgent
|
|
||||||
? "Upload documents"
|
|
||||||
: "Upload"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -294,24 +186,6 @@ export function ActionNeededSection({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<ModalSafeWrapper>
|
<ModalSafeWrapper>
|
||||||
<Modal
|
|
||||||
opened={clearanceId !== null}
|
|
||||||
onClose={() => setClearanceId(null)}
|
|
||||||
title={
|
|
||||||
<Text fw={700} fz={16}>
|
|
||||||
Clearance documents
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
size="xl"
|
|
||||||
radius="md"
|
|
||||||
centered
|
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
||||||
>
|
|
||||||
{clearanceId && (
|
|
||||||
<ContractClearancePanel contractId={clearanceId} bare />
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payItem !== null}
|
opened={payItem !== null}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -521,9 +521,9 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
iconColor: "edr-slate",
|
iconColor: "edr-slate",
|
||||||
tile: "edr-slate-soft2",
|
tile: "edr-slate-soft2",
|
||||||
hint: "Contract closed · quantity used or window elapsed",
|
hint: "Contract completed · shipment delivered, quantity used or window elapsed",
|
||||||
step: "edr-step",
|
step: "edr-step",
|
||||||
badgeLabel: "Closed",
|
badgeLabel: "Completed",
|
||||||
badgeBg: "edr-slate-soft2",
|
badgeBg: "edr-slate-soft2",
|
||||||
badgeText: "edr-slate",
|
badgeText: "edr-slate",
|
||||||
badgeDot: "edr-step",
|
badgeDot: "edr-step",
|
||||||
|
|||||||
@@ -1,29 +1,71 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Anchor, Button, FileInput, Group, Paper, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileInput,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileBadge,
|
||||||
|
Receipt,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { downloadStoredFile } from "@/services/files.service";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { GREEN, INK } from "../contracts/contract-ui";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
||||||
|
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
DRAFT: "Draft",
|
||||||
|
ISSUED: "Issued",
|
||||||
|
PENDING: "Due",
|
||||||
|
PARTIALLY_PAID: "Partially paid",
|
||||||
|
PAID: "Paid",
|
||||||
|
OVERDUE: "Overdue",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
|
REFUNDED: "Refunded",
|
||||||
|
EXPIRED: "Expired",
|
||||||
|
};
|
||||||
|
|
||||||
|
function invoiceStatusLabel(status: string): string {
|
||||||
|
return (
|
||||||
|
INVOICE_STATUS_LABELS[status] ??
|
||||||
|
status
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function BookingClearanceWorkflowBanner({
|
export function BookingClearanceWorkflowBanner({
|
||||||
booking,
|
booking,
|
||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
}) {
|
}) {
|
||||||
// Drawdown bookings under a GENERAL contract keep bookingType = ONE_TIME —
|
// Every contract booking whose service bundles customs runs the phased GL
|
||||||
// the denormalized contractKind is what marks them as phased (Path B).
|
// workflow now — ONE_TIME and GENERAL alike.
|
||||||
const isPhased =
|
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
|
||||||
booking.customsClearingEnabled &&
|
|
||||||
(booking.bookingType === "GENERAL_CONTRACT" ||
|
|
||||||
booking.contractKind === "GENERAL");
|
|
||||||
|
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
@@ -75,6 +117,45 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{clearance.riskLevel ? (
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Text fw={700} fz={14} c={INK}>
|
||||||
|
Customs risk level
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={CUSTOMS_RISK_COLOR[clearance.riskLevel] ?? "gray"}
|
||||||
|
variant="filled"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{clearance.riskLevel.charAt(0) +
|
||||||
|
clearance.riskLevel.slice(1).toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
{clearance.riskAssignedAt ? (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
assigned {new Date(clearance.riskAssignedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.secondDuty?.advised ? (
|
||||||
|
<SecondDutyDueCard
|
||||||
|
duty={clearance.secondDuty}
|
||||||
|
bookingId={booking.id}
|
||||||
|
onView={(f) => view(f)}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.finalInvoice ? (
|
||||||
|
<FinalInvoiceDueCard
|
||||||
|
invoice={clearance.finalInvoice}
|
||||||
|
bookingId={booking.id}
|
||||||
|
onView={(f) => view(f)}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{clearance.operationReady ? (
|
{clearance.operationReady ? (
|
||||||
<Alert color="green" variant="light">
|
<Alert color="green" variant="light">
|
||||||
Clearance is complete. You may proceed to request your operation date.
|
Clearance is complete. You may proceed to request your operation date.
|
||||||
@@ -173,3 +254,295 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.ClearanceView["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">
|
||||||
|
{invoiceStatusLabel(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.ClearanceView["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,6 +14,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
|
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||||
import { DocumentsTab } from "./components/DocumentsTab";
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
@@ -242,6 +243,10 @@ export function ReadonlyBookingView({
|
|||||||
|
|
||||||
{isClearance && <ClearanceCard booking={booking} />}
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
|
{/* Customs (Path B) shipments: GL's phased progress, the duty /
|
||||||
|
additional-duty payments and the final invoice — all per booking. */}
|
||||||
|
<BookingClearanceWorkflowBanner booking={booking} />
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { ArrowLeft, Upload } from "lucide-react";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { ContractStatusBadge, INK } from "./contract-ui";
|
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
|
|
||||||
* upload logic live in {@link ContractClearancePanel} so the same workspace can
|
|
||||||
* render here (full page) or inside the contract action modal.
|
|
||||||
*/
|
|
||||||
export default function ContractClearanceFlow() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { data: contract } = useQuery(
|
|
||||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: clearance } = useQuery(
|
|
||||||
api.contracts.getClearance.queryOptions({
|
|
||||||
input: { id: id! },
|
|
||||||
enabled: !!id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
||||||
<Group gap="md" align="center" wrap="nowrap">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
px={8}
|
|
||||||
onClick={() => navigate(`/contracts/${id}`)}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</Button>
|
|
||||||
<Group gap={12} wrap="nowrap" align="center">
|
|
||||||
<ThemeIcon size={48} radius="lg" variant="light" color="edr-green">
|
|
||||||
<Upload size={23} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Group gap={10} align="center">
|
|
||||||
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
|
|
||||||
Clearance documents
|
|
||||||
</Title>
|
|
||||||
{contract && <ContractStatusBadge status={contract.status} />}
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed" mt={2}>
|
|
||||||
{contract?.reference ?? ""} · Cycle{" "}
|
|
||||||
{clearance?.cycleNumber ?? 1}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{id && (
|
|
||||||
<ContractClearancePanel
|
|
||||||
contractId={id}
|
|
||||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Center,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { isViewable } from "@edr/ui-common";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
|
||||||
import {
|
|
||||||
ClearanceAdHocUploadSection,
|
|
||||||
type AdHocDoc,
|
|
||||||
} from "@/components/contracts/ClearanceAdHocUploadSection";
|
|
||||||
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
|
||||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
|
||||||
|
|
||||||
export interface ContractClearancePanelProps {
|
|
||||||
contractId: string;
|
|
||||||
tradeDirection?: string;
|
|
||||||
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
|
||||||
bare?: boolean;
|
|
||||||
/** Called after a successful document submission (e.g. to close the host modal). */
|
|
||||||
onSubmitted?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The clearance document workspace for a contract: shows the customer-input doc
|
|
||||||
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
|
|
||||||
* after first submission), and surfaces query notes. Rendered both on the
|
|
||||||
* standalone clearance page and inside the contract action modal. See
|
|
||||||
* docs/new-doc.md §8.3.
|
|
||||||
*/
|
|
||||||
export function ContractClearancePanel({
|
|
||||||
contractId,
|
|
||||||
tradeDirection = "IMPORT",
|
|
||||||
bare,
|
|
||||||
onSubmitted,
|
|
||||||
}: ContractClearancePanelProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [pending, setPending] = useState<Record<string, File>>({});
|
|
||||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
|
||||||
const { view, viewer } = useFileViewer();
|
|
||||||
|
|
||||||
const clearanceQuery = useQuery(
|
|
||||||
api.contracts.getClearance.queryOptions({
|
|
||||||
input: { id: contractId },
|
|
||||||
enabled: !!contractId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const clearance = clearanceQuery.data;
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
|
||||||
onSuccess: () => {
|
|
||||||
setPending({});
|
|
||||||
setAdHoc([]);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
|
||||||
});
|
|
||||||
// Submitting moves the contract to CLEARANCE_UNDER_REVIEW — refresh every
|
|
||||||
// contracts-list query (prefix key) so the /contracts table row and its
|
|
||||||
// action button update without a page reload.
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.list.queryKey(),
|
|
||||||
});
|
|
||||||
onSubmitted?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const customerDocs = useMemo(() => {
|
|
||||||
const docs = (clearance?.documents ?? []).filter(
|
|
||||||
(d) => d.uploadedBy === "customer",
|
|
||||||
);
|
|
||||||
// Surface queried documents (the ones needing correction) first.
|
|
||||||
const rank = (s: string | null) =>
|
|
||||||
s === "QUERIED" ? 0 : s === "APPROVED" ? 2 : 1;
|
|
||||||
return [...docs].sort(
|
|
||||||
(a, b) => rank(a.reviewStatus) - rank(b.reviewStatus),
|
|
||||||
);
|
|
||||||
}, [clearance]);
|
|
||||||
|
|
||||||
const queriedCount = useMemo(
|
|
||||||
() => customerDocs.filter((d) => d.reviewStatus === "QUERIED").length,
|
|
||||||
[customerDocs],
|
|
||||||
);
|
|
||||||
const glDocs = useMemo(
|
|
||||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
|
||||||
[clearance],
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
|
||||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
|
||||||
const isReady =
|
|
||||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
status === "SELF_CLEARED" ||
|
|
||||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
|
||||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
|
||||||
|
|
||||||
const customsPath = clearance?.includesCustoms ?? true;
|
|
||||||
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
|
||||||
|
|
||||||
const missingRequired = useMemo(
|
|
||||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
|
||||||
[customerDocs, pending],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasStagedFiles =
|
|
||||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
|
||||||
|
|
||||||
const canSubmit = isInitialUpload
|
|
||||||
? hasStagedFiles && missingRequired.length === 0
|
|
||||||
: hasStagedFiles;
|
|
||||||
|
|
||||||
const stagePending = (fileKey: string, file: File | null) =>
|
|
||||||
setPending((p) => {
|
|
||||||
if (file) return { ...p, [fileKey]: file };
|
|
||||||
const next = { ...p };
|
|
||||||
delete next[fileKey];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const submitDocuments = () => {
|
|
||||||
const files: Record<string, File | null> = { ...pending };
|
|
||||||
adHoc.forEach((row, i) => {
|
|
||||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
|
||||||
});
|
|
||||||
if (Object.keys(files).length === 0) return;
|
|
||||||
uploadMutation.mutate({ id: contractId, files });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (clearanceQuery.isLoading) {
|
|
||||||
return (
|
|
||||||
<Center mih={bare ? 200 : 400} p="xl">
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{queriedCount > 0 && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
icon={<AlertCircle size={18} />}
|
|
||||||
mb="md"
|
|
||||||
title={`${queriedCount} document${queriedCount > 1 ? "s" : ""} need correction`}
|
|
||||||
>
|
|
||||||
Re-upload the highlighted document{queriedCount > 1 ? "s" : ""} below to
|
|
||||||
continue. The reviewer's note explains what to fix.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{isReady ? (
|
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
|
||||||
{customsPath
|
|
||||||
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due."
|
|
||||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
|
||||||
</Alert>
|
|
||||||
) : isUnderReview ? (
|
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
|
||||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
|
|
||||||
your documents. Only re-upload the documents flagged with a query
|
|
||||||
below — approved documents stay as they are.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
|
||||||
{customsPath
|
|
||||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
|
||||||
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isInitialUpload && missingRequired.length > 0 && (
|
|
||||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
|
||||||
<Text fz="12px" c="#9A5B00">
|
|
||||||
Still required: {missingRequired.map((d) => d.label).join(", ")}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Required customer documents */}
|
|
||||||
<Stack gap="md">
|
|
||||||
<Box>
|
|
||||||
<Text fz={13} fw={700} c="#10202F">
|
|
||||||
Your clearance documents
|
|
||||||
</Text>
|
|
||||||
<Text fz={12} c="dimmed" mt={4}>
|
|
||||||
Upload each required document below. Items marked * are mandatory.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{customerDocs.map((doc) => (
|
|
||||||
<ClearanceDocumentUploadCard
|
|
||||||
key={doc.fileKey}
|
|
||||||
label={doc.label}
|
|
||||||
required={doc.required}
|
|
||||||
reviewStatus={doc.reviewStatus}
|
|
||||||
note={doc.note}
|
|
||||||
uploadedFile={doc.file}
|
|
||||||
stagedFile={pending[doc.fileKey] ?? null}
|
|
||||||
canUpload={canUpload}
|
|
||||||
onStageFile={
|
|
||||||
canUpload && doc.reviewStatus !== "APPROVED"
|
|
||||||
? (file) => stagePending(doc.fileKey, file)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onPreview={view}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{customerDocs.length === 0 && (
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
No clearance documents are configured for this contract yet.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* GL output documents (read-only). */}
|
|
||||||
{glDocs.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
|
||||||
Customs output documents
|
|
||||||
</Text>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{glDocs.map((doc) => (
|
|
||||||
<Group
|
|
||||||
key={doc.fileKey}
|
|
||||||
justify="space-between"
|
|
||||||
wrap="nowrap"
|
|
||||||
className="rounded-xl"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
|
|
||||||
>
|
|
||||||
<Text fz="13px" c="#10202F" truncate>
|
|
||||||
{doc.label}
|
|
||||||
</Text>
|
|
||||||
{doc.file ? (
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
{isViewable({
|
|
||||||
name: doc.file.name,
|
|
||||||
url: "",
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(
|
|
||||||
doc.file!.id,
|
|
||||||
doc.file!.name,
|
|
||||||
).then(view)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconSquare
|
|
||||||
icon={<Download size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
void downloadStoredFile(doc.file!.id, doc.file!.name)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Text fz="12px" c="#9AA8B5">
|
|
||||||
Pending
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box mt="lg">
|
|
||||||
<ClearanceUploadedDocumentsPanel
|
|
||||||
embedded
|
|
||||||
tradeDirection={tradeDirection}
|
|
||||||
files={clearance?.workflowFiles ?? []}
|
|
||||||
title="Customs workflow documents"
|
|
||||||
onView={(f) => view(f)}
|
|
||||||
onDownload={({ id, name }) => void downloadStoredFile(id, name)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{canUpload ? (
|
|
||||||
<ClearanceAdHocUploadSection
|
|
||||||
rows={adHoc}
|
|
||||||
onAdd={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
|
||||||
onRemove={(i) => setAdHoc((rows) => rows.filter((_, j) => j !== i))}
|
|
||||||
onNameChange={(i, name) =>
|
|
||||||
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, name } : r)))
|
|
||||||
}
|
|
||||||
onFileChange={(i, file) =>
|
|
||||||
setAdHoc((rows) => rows.map((r, j) => (j === i ? { ...r, file } : r)))
|
|
||||||
}
|
|
||||||
onPreview={view}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
|
||||||
{uploadMutation.error instanceof Error
|
|
||||||
? uploadMutation.error.message
|
|
||||||
: "Upload failed. Please try again."}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canUpload && (
|
|
||||||
<Group justify="flex-end" mt="xl">
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
size="md"
|
|
||||||
leftSection={<Upload size={16} />}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
loading={uploadMutation.isPending}
|
|
||||||
onClick={submitDocuments}
|
|
||||||
>
|
|
||||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewer}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (bare) return body;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
|
||||||
{body}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContractClearancePanel;
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Alert, Box, Button, Group, Paper, Stack, Text, Textarea } from "@mantine/core";
|
|
||||||
import { AlertTriangle, ArrowRight, Download, MessageSquareWarning, PackageCheck, Receipt, Upload } from "lucide-react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
|
||||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
|
||||||
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
|
||||||
import { BORDER, INK } from "./contract-ui";
|
|
||||||
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
|
||||||
|
|
||||||
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
|
|
||||||
void downloadStoredFile(id, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractClearanceWorkflowBanner({
|
|
||||||
contract,
|
|
||||||
}: {
|
|
||||||
contract: Freight.IContract;
|
|
||||||
}) {
|
|
||||||
const { view, viewer } = useFileViewer();
|
|
||||||
const isPhased =
|
|
||||||
contract.customsClearingEnabled && contract.contractKind === "ONE_TIME";
|
|
||||||
|
|
||||||
const { data: clearance, refetch } = useQuery({
|
|
||||||
queryKey: ["contract-clearance", contract.id],
|
|
||||||
queryFn: () => contractsService.getClearance(contract.id),
|
|
||||||
enabled: isPhased,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isPhased || !clearance) return null;
|
|
||||||
|
|
||||||
const dutyPaid = clearance.milestones?.some(
|
|
||||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
|
||||||
);
|
|
||||||
const dutyPending =
|
|
||||||
clearance.dutyRequired &&
|
|
||||||
clearance.dutyAdvice &&
|
|
||||||
!dutyPaid;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text fw={700} size="sm" style={{ color: INK }}>
|
|
||||||
Clearance progress
|
|
||||||
</Text>
|
|
||||||
<ClearancePhaseStepper
|
|
||||||
clearance={clearance}
|
|
||||||
tradeDirection={contract.tradeDirection}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
{clearance.roHold && clearance.roHoldReason ? (
|
|
||||||
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
|
||||||
{clearance.roHoldReason}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{clearance.nextAction?.actor === "CUSTOMER" ? (
|
|
||||||
<Alert color="blue" variant="light">
|
|
||||||
{clearance.nextAction.action}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Duty is disputed: GL owes a corrected advice, so the pay/upload
|
|
||||||
panel is replaced by the waiting state until they re-send it. */}
|
|
||||||
{clearance.dutyDispute ? (
|
|
||||||
<Alert
|
|
||||||
color="orange"
|
|
||||||
variant="light"
|
|
||||||
icon={<MessageSquareWarning size={16} />}
|
|
||||||
title="Waiting for a corrected duty amount"
|
|
||||||
>
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text fz={13}>
|
|
||||||
You asked GL Ethiopia to review the advised duty & tax. They
|
|
||||||
will send a corrected notice — you will be notified.
|
|
||||||
</Text>
|
|
||||||
<Text fz={12} c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
|
||||||
Your message: {clearance.dutyDispute.note}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
) : dutyPending && clearance.dutyAdvice ? (
|
|
||||||
<DutyAdvicePanel
|
|
||||||
dutyAdvice={clearance.dutyAdvice}
|
|
||||||
contractId={contract.id}
|
|
||||||
onUploaded={() => void refetch()}
|
|
||||||
onPreview={view}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ClearanceUploadedDocumentsPanel
|
|
||||||
embedded
|
|
||||||
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
|
||||||
files={clearance.workflowFiles ?? []}
|
|
||||||
onView={view}
|
|
||||||
onDownload={downloadWorkflowFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{clearance.linkedBookingId ? (
|
|
||||||
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
|
|
||||||
<Stack gap={6}>
|
|
||||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
|
||||||
Shipment booking created
|
|
||||||
{clearance.linkedBookingReference
|
|
||||||
? ` · ${clearance.linkedBookingReference}`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
|
||||||
<Text fz={12} c="dimmed">
|
|
||||||
Global Logistics has created your shipment booking
|
|
||||||
{clearance.linkedBookingStatus
|
|
||||||
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
|
|
||||||
: ""}
|
|
||||||
. Track its progress from the booking.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="light"
|
|
||||||
color="green"
|
|
||||||
leftSection={<ArrowRight size={14} />}
|
|
||||||
component="a"
|
|
||||||
href={`/bookings/${clearance.linkedBookingId}`}
|
|
||||||
style={{ alignSelf: "flex-start" }}
|
|
||||||
>
|
|
||||||
View shipment booking
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
) : clearance.bookingReady ? (
|
|
||||||
<Alert color="green" variant="light">
|
|
||||||
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
{viewer}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DutyAdvicePanel({
|
|
||||||
dutyAdvice,
|
|
||||||
contractId,
|
|
||||||
onUploaded,
|
|
||||||
onPreview,
|
|
||||||
}: {
|
|
||||||
dutyAdvice: NonNullable<Freight.ContractClearanceView["dutyAdvice"]>;
|
|
||||||
contractId: string;
|
|
||||||
onUploaded: () => void;
|
|
||||||
onPreview: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
|
||||||
}) {
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
// Accept → pay and upload the slip. Query → say what is wrong and send it
|
|
||||||
// back to GL Ethiopia for a corrected notice.
|
|
||||||
const [disputing, setDisputing] = useState(false);
|
|
||||||
const [disputeNote, setDisputeNote] = useState("");
|
|
||||||
const [sendingDispute, setSendingDispute] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
borderColor: "#F5D9A8",
|
|
||||||
background: "linear-gradient(160deg, #FFFBF0 0%, #FFFFFF 70%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 8,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "#FFF3D6",
|
|
||||||
color: "#C77F09",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Receipt size={16} />
|
|
||||||
</Box>
|
|
||||||
<Text fw={700} fz={14} style={{ color: INK }}>
|
|
||||||
Duty / tax payment
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Paper withBorder radius="md" p="sm" bg="#fff">
|
|
||||||
<Text fz={13} style={{ color: INK }}>
|
|
||||||
Amount due:{" "}
|
|
||||||
<strong>
|
|
||||||
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
|
||||||
</strong>
|
|
||||||
{dutyAdvice.declarationSerial
|
|
||||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
|
||||||
: null}
|
|
||||||
</Text>
|
|
||||||
{dutyAdvice.noticeFile ? (
|
|
||||||
<Group gap={8} mt={8}>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="orange"
|
|
||||||
leftSection={<Download size={14} />}
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
void downloadStoredFile(
|
|
||||||
dutyAdvice.noticeFile!.id,
|
|
||||||
dutyAdvice.noticeFile!.name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Download duty notice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(
|
|
||||||
dutyAdvice.noticeFile!.id,
|
|
||||||
dutyAdvice.noticeFile!.name,
|
|
||||||
).then(onPreview)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Preview notice
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
) : null}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{disputing ? (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text fz={13} c="dimmed">
|
|
||||||
Tell GL Ethiopia what is wrong with this amount. They will review
|
|
||||||
and send a corrected notice.
|
|
||||||
</Text>
|
|
||||||
<Textarea
|
|
||||||
label="What needs correcting?"
|
|
||||||
placeholder="e.g. the declared value is wrong — the invoice total is 412,000 ETB"
|
|
||||||
value={disputeNote}
|
|
||||||
onChange={(e) => setDisputeNote(e.currentTarget.value)}
|
|
||||||
autosize
|
|
||||||
minRows={3}
|
|
||||||
/>
|
|
||||||
<Group gap="sm" grow>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
setDisputing(false);
|
|
||||||
setDisputeNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="orange"
|
|
||||||
loading={sendingDispute}
|
|
||||||
disabled={!disputeNote.trim()}
|
|
||||||
onClick={async () => {
|
|
||||||
setSendingDispute(true);
|
|
||||||
try {
|
|
||||||
await contractsService.disputeContractDuty(
|
|
||||||
contractId,
|
|
||||||
disputeNote.trim(),
|
|
||||||
);
|
|
||||||
toast.success("Sent back to GL Ethiopia for correction");
|
|
||||||
setDisputing(false);
|
|
||||||
setDisputeNote("");
|
|
||||||
onUploaded();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Could not send");
|
|
||||||
} finally {
|
|
||||||
setSendingDispute(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Send for correction
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text fz={13} c="dimmed">
|
|
||||||
Pay the amount above, then upload your payment slip so clearance can
|
|
||||||
continue. If the amount looks wrong, ask GL Ethiopia to correct it
|
|
||||||
before paying.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PortalFileDropzone
|
|
||||||
label="Payment slip"
|
|
||||||
description="Upload proof of duty/tax payment (PDF or image)."
|
|
||||||
value={file}
|
|
||||||
onChange={setFile}
|
|
||||||
onPreview={onPreview}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="orange"
|
|
||||||
loading={loading}
|
|
||||||
disabled={!file}
|
|
||||||
leftSection={<Upload size={16} />}
|
|
||||||
fullWidth
|
|
||||||
onClick={async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await contractsService.uploadContractDutySlip(contractId, file);
|
|
||||||
toast.success("Payment slip uploaded");
|
|
||||||
setFile(null);
|
|
||||||
onUploaded();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Accept & submit payment slip
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="orange"
|
|
||||||
leftSection={<MessageSquareWarning size={15} />}
|
|
||||||
fullWidth
|
|
||||||
onClick={() => setDisputing(true)}
|
|
||||||
>
|
|
||||||
Request a change to this amount
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||||
Navigate,
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -13,9 +8,9 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
FileInput,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
Progress,
|
Progress,
|
||||||
RingProgress,
|
RingProgress,
|
||||||
@@ -24,10 +19,10 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -49,9 +44,8 @@ import {
|
|||||||
Split,
|
Split,
|
||||||
Upload,
|
Upload,
|
||||||
Weight,
|
Weight,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Modal } from "@mantine/core";
|
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { clearanceWorkflowFileLabel } from "@edr/types";
|
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||||
@@ -68,9 +62,6 @@ import {
|
|||||||
PaymentBadge,
|
PaymentBadge,
|
||||||
SchedulingCell,
|
SchedulingCell,
|
||||||
} from "@/pages/bookings/booking-display";
|
} from "@/pages/bookings/booking-display";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
|
||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
@@ -85,15 +76,6 @@ import {
|
|||||||
MUTED,
|
MUTED,
|
||||||
} from "./contract-ui";
|
} from "./contract-ui";
|
||||||
|
|
||||||
// Statuses where the customer uploads clearance documents on the contract. Used
|
|
||||||
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
|
|
||||||
// (non-customs IMPORT/EXPORT, Operations-reviewed).
|
|
||||||
const CLEARANCE_UPLOAD_STATUSES = [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
|
||||||
];
|
|
||||||
|
|
||||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||||
|
|
||||||
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
|
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
|
||||||
@@ -104,29 +86,6 @@ const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
|
|||||||
CANCELLED: "Cancelled",
|
CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
|
||||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
|
||||||
DRAFT: "Draft",
|
|
||||||
ISSUED: "Issued",
|
|
||||||
PENDING: "Due",
|
|
||||||
PARTIALLY_PAID: "Partially paid",
|
|
||||||
PAID: "Paid",
|
|
||||||
OVERDUE: "Overdue",
|
|
||||||
CANCELLED: "Cancelled",
|
|
||||||
REFUNDED: "Refunded",
|
|
||||||
EXPIRED: "Expired",
|
|
||||||
};
|
|
||||||
|
|
||||||
function invoiceStatusLabel(status: string): string {
|
|
||||||
return (
|
|
||||||
INVOICE_STATUS_LABELS[status] ??
|
|
||||||
status
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Business-license document codes — surfaced as their own section so they stand
|
// Business-license document codes — surfaced as their own section so they stand
|
||||||
// out from the rest of the onboarding/profile set.
|
// out from the rest of the onboarding/profile set.
|
||||||
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
||||||
@@ -215,24 +174,45 @@ function groupContractDocuments(
|
|||||||
].filter((g) => g.files.length > 0);
|
].filter((g) => g.files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses where the customer's own cancel is pointless or not theirs to make:
|
||||||
|
* already over, or frozen by EDR (only staff can lift that).
|
||||||
|
*/
|
||||||
|
const CANCEL_BLOCKED_STATUSES = [
|
||||||
|
"REJECTED",
|
||||||
|
"CANCELLED",
|
||||||
|
"CONTRACT_CLOSED",
|
||||||
|
"ARCHIVED",
|
||||||
|
"EXPIRED",
|
||||||
|
"SUSPENDED",
|
||||||
|
];
|
||||||
|
|
||||||
export default function ContractDetailPage() {
|
export default function ContractDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<string>("details");
|
const [tab, setTab] = useState<string>("details");
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const [clearanceOpen, clearanceModal] = useDisclosure(false);
|
// Declared up here with the other hooks — the page early-returns further down
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
// (loading / error / redirect-to-edit), so a hook below those would be
|
||||||
|
// conditional.
|
||||||
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
|
const cancelContract = useMutation({
|
||||||
// opens the clearance step modal directly.
|
mutationFn: (reason: string | undefined) =>
|
||||||
useEffect(() => {
|
contractsService.cancel(id!, reason),
|
||||||
if (searchParams.get("action") === "clearance") {
|
onSuccess: () => {
|
||||||
clearanceModal.open();
|
toast.success("Contract cancelled");
|
||||||
searchParams.delete("action");
|
void queryClient.invalidateQueries({ queryKey: ["contracts"] });
|
||||||
setSearchParams(searchParams, { replace: true });
|
navigate("/contracts");
|
||||||
}
|
},
|
||||||
}, [searchParams, clearanceModal, setSearchParams]);
|
onError: (error: unknown) => {
|
||||||
|
const message =
|
||||||
|
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Could not cancel this contract.";
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
data: contract,
|
data: contract,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -249,22 +229,6 @@ export default function ContractDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clearance view — drives queries alert, workflow documents, and duty panels.
|
|
||||||
const inClearance =
|
|
||||||
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
|
||||||
const isPhasedCustomsClearance =
|
|
||||||
!!contract &&
|
|
||||||
contract.customsClearingEnabled &&
|
|
||||||
contract.contractKind === "ONE_TIME";
|
|
||||||
const { data: clearanceView, refetch: refetchClearance } = useQuery({
|
|
||||||
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
|
||||||
enabled: !!id && (inClearance || isPhasedCustomsClearance),
|
|
||||||
});
|
|
||||||
const workflowFiles = clearanceView?.workflowFiles ?? [];
|
|
||||||
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
|
||||||
const queriedCount = (clearanceView?.documents ?? []).filter(
|
|
||||||
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const showShipmentRequests =
|
const showShipmentRequests =
|
||||||
!!contract &&
|
!!contract &&
|
||||||
@@ -375,16 +339,9 @@ export default function ContractDetailPage() {
|
|||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
const pricing = contract.pricingBreakdown;
|
const pricing = contract.pricingBreakdown;
|
||||||
const files = contract.files ?? [];
|
const files = contract.files ?? [];
|
||||||
// GENERAL contracts clear per booking — clearance documents live on each
|
// Clearance documents live on each booking now, so the contract's Documents
|
||||||
// booking's detail page, so this tab keeps only profile/licence documents.
|
// tab keeps only its own attachments (profile, licence, signed contract).
|
||||||
// Clearance upload field keys (when the clearance view is loaded) let the
|
const docGroups = groupContractDocuments(files, { includeClearance: false });
|
||||||
// grouping tell real clearance docs apart from other attachments.
|
|
||||||
const docGroups = groupContractDocuments(files, {
|
|
||||||
includeClearance: contract.contractKind !== "GENERAL",
|
|
||||||
clearanceKeys: new Set(
|
|
||||||
(clearanceView?.documents ?? []).map((d) => d.fileKey),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
|
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||||
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
||||||
// in the header (it's excluded from the Documents tab groups).
|
// in the header (it's excluded from the Documents tab groups).
|
||||||
@@ -407,18 +364,20 @@ export default function ContractDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canSign = contract.status === "CONTRACT_READY";
|
const canSign = contract.status === "CONTRACT_READY";
|
||||||
|
|
||||||
|
// The customer may drop their own contract at any live stage so they can
|
||||||
|
// request a different one on the same lane — but not while a shipment under
|
||||||
|
// it is still running, and not while EDR has it suspended. The API enforces
|
||||||
|
// both; this only decides whether the button is offered.
|
||||||
|
const activeShipments = contract.activeBookingCount ?? 0;
|
||||||
|
const cancelStatusOk = !CANCEL_BLOCKED_STATUSES.includes(contract.status);
|
||||||
|
const cancellable = cancelStatusOk && activeShipments === 0;
|
||||||
|
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
// Intercity (DOMESTIC) has no customs — the document gate collects the
|
|
||||||
// admin-configured intercity set, reviewed by Operations.
|
|
||||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
|
||||||
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
|
|
||||||
// Only the NON-customs (Path A) customer books himself — once the contract is
|
// Only the NON-customs (Path A) customer books himself — once the contract is
|
||||||
// executed after self-clearance. Customs (Path B) bookings are created by
|
// executed after self-clearance. Customs (Path B) bookings are created by
|
||||||
// Global Logistics on the customer's behalf, so the customer gets no booking
|
// Global Logistics on the customer's behalf, so the customer gets no booking
|
||||||
// button on a customs contract.
|
// button on a customs contract.
|
||||||
const clearanceFinalized =
|
|
||||||
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
|
||||||
const bookingAction = getContractBookingAction(contract, contractBookings);
|
const bookingAction = getContractBookingAction(contract, contractBookings);
|
||||||
// Whether the customer may open a new self-service booking. Derived from the
|
// Whether the customer may open a new self-service booking. Derived from the
|
||||||
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
|
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
|
||||||
@@ -427,16 +386,9 @@ export default function ContractDetailPage() {
|
|||||||
const canBookShipment =
|
const canBookShipment =
|
||||||
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
||||||
const canRequestShipment = bookingAction.kind === "request";
|
const canRequestShipment = bookingAction.kind === "request";
|
||||||
// GENERAL non-customs import/export: one-click bare booking instance — the
|
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
||||||
// per-booking clearance runs first, so no window gate applies here.
|
// instance — the per-booking clearance runs first, so no window gate here.
|
||||||
const canInitiateBooking = bookingAction.kind === "initiate";
|
const canInitiateBooking = bookingAction.kind === "initiate";
|
||||||
// Customs + clearance finalized: GL is preparing the booking — surface a
|
|
||||||
// status notice instead of any action.
|
|
||||||
const glPreparingBooking = customsPath && clearanceFinalized;
|
|
||||||
// The customer uploads clearance documents while in a clearance status, until
|
|
||||||
// clearance is finalized.
|
|
||||||
const canUploadClearance =
|
|
||||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
@@ -559,33 +511,37 @@ export default function ContractDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
{glPreparingBooking && (
|
{cancelStatusOk && (
|
||||||
<Badge
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
variant="light"
|
|
||||||
color="teal"
|
|
||||||
leftSection={<CheckCircle2 size={14} />}
|
|
||||||
>
|
|
||||||
Global Logistics is creating your booking
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{canUploadClearance && (
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
variant="light"
|
||||||
|
color="red"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<XCircle size={16} />}
|
||||||
onClick={clearanceModal.open}
|
disabled={!cancellable}
|
||||||
|
title={
|
||||||
|
cancellable
|
||||||
|
? undefined
|
||||||
|
: `${activeShipments} shipment(s) still running on this contract`
|
||||||
|
}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
>
|
>
|
||||||
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
Cancel contract
|
||||||
? `Manage ${docNoun}`
|
|
||||||
: `Upload ${docNoun}`}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{contract.status === "SUSPENDED" && (
|
||||||
|
<Alert color="orange" radius="md" title="Contract suspended by EDR">
|
||||||
|
{contract.latestSuspensionNote
|
||||||
|
? `Reason: ${contract.latestSuspensionNote}`
|
||||||
|
: "This contract is on hold."}{" "}
|
||||||
|
No new shipments can be booked and shipments already under it are
|
||||||
|
paused until EDR lifts the suspension.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key facts — one premium gradient card */}
|
{/* Key facts — one premium gradient card */}
|
||||||
<Paper
|
<Paper
|
||||||
radius={18}
|
radius={18}
|
||||||
@@ -680,7 +636,7 @@ export default function ContractDetailPage() {
|
|||||||
active={tab === "documents"}
|
active={tab === "documents"}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
label="Documents"
|
label="Documents"
|
||||||
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
count={docCount}
|
||||||
/>
|
/>
|
||||||
<DetailTab
|
<DetailTab
|
||||||
value="bookings"
|
value="bookings"
|
||||||
@@ -717,174 +673,6 @@ export default function ContractDetailPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|
||||||
{/* Clearance notice (both paths) */}
|
|
||||||
{queriedCount > 0 && (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
p="lg"
|
|
||||||
style={{
|
|
||||||
borderColor: "#F0B4B4",
|
|
||||||
background: "#FDF4F4",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 3,
|
|
||||||
background: "#D64545",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
|
||||||
<Group gap={10} align="flex-start" wrap="nowrap">
|
|
||||||
<AlertTriangle size={18} color="#D64545" style={{ marginTop: 2 }} />
|
|
||||||
<div>
|
|
||||||
<Text fw={700} fz={15} c="#7A1F1F">
|
|
||||||
{queriedCount} document{queriedCount > 1 ? "s" : ""} need
|
|
||||||
correction
|
|
||||||
</Text>
|
|
||||||
<Text fz={13} c="#9A4A4A" mt={2}>
|
|
||||||
A reviewer sent back document
|
|
||||||
{queriedCount > 1 ? "s" : ""} with a query. Re-upload the
|
|
||||||
corrected file{queriedCount > 1 ? "s" : ""} to continue.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Upload size={15} />}
|
|
||||||
onClick={clearanceModal.open}
|
|
||||||
>
|
|
||||||
Upload corrected documents
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customsPath && contract.contractKind === "ONE_TIME" ? (
|
|
||||||
<ContractClearanceWorkflowBanner contract={contract} />
|
|
||||||
) : 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.charAt(0) +
|
|
||||||
clearanceView.riskLevel.slice(1).toLowerCase()}
|
|
||||||
</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 && (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
p="lg"
|
|
||||||
style={{
|
|
||||||
borderColor: "#CDEBDD",
|
|
||||||
background: "#F6FBF8",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 3,
|
|
||||||
background: GREEN,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Group gap={10} align="center" mb={6}>
|
|
||||||
<Upload size={16} color={GREEN} />
|
|
||||||
<Text fw={700} fz={15} c={INK}>
|
|
||||||
{customsPath
|
|
||||||
? "Customs clearance shipment"
|
|
||||||
: isIntercity
|
|
||||||
? "Intercity documents required"
|
|
||||||
: "Customs clearance required"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fz={13} c="dimmed">
|
|
||||||
{customsPath
|
|
||||||
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
||||||
? "Upload your clearance documents so Global Logistics can review them. Once they finalize the clearance you can create your shipment booking."
|
|
||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
||||||
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
|
||||||
: "Your documents are cleared. You can now create a shipment booking under this contract."
|
|
||||||
: isIntercity
|
|
||||||
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
||||||
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
|
|
||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
||||||
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
|
|
||||||
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
|
|
||||||
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
||||||
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
|
|
||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
||||||
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
|
||||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
|
||||||
</Text>
|
|
||||||
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
|
|
||||||
<Button
|
|
||||||
mt="md"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Upload size={15} />}
|
|
||||||
onClick={clearanceModal.open}
|
|
||||||
>
|
|
||||||
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
||||||
? "Upload documents"
|
|
||||||
: "Manage documents"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unit-rate schedule */}
|
{/* Unit-rate schedule */}
|
||||||
<Card
|
<Card
|
||||||
@@ -1213,21 +1001,6 @@ export default function ContractDetailPage() {
|
|||||||
{/* ── Documents tab ─────────────────────────────────────────── */}
|
{/* ── Documents tab ─────────────────────────────────────────── */}
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{isPhasedCustomsClearance ? (
|
|
||||||
<ClearanceUploadedDocumentsPanel
|
|
||||||
files={workflowFiles}
|
|
||||||
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
|
||||||
onView={view}
|
|
||||||
onDownload={async (f) => {
|
|
||||||
try {
|
|
||||||
await downloadStoredFile(f.id, f.name);
|
|
||||||
} catch {
|
|
||||||
toast.error("Could not download file.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{docGroups.length === 0 ? (
|
{docGroups.length === 0 ? (
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
@@ -1255,7 +1028,7 @@ export default function ContractDetailPage() {
|
|||||||
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
||||||
{isGeneral
|
{isGeneral
|
||||||
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
|
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
|
||||||
: "The signed contract and any uploaded clearance documents will appear here."}
|
: "The signed contract and your company documents appear here. Clearance documents are managed on each booking."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1456,9 +1229,9 @@ export default function ContractDetailPage() {
|
|||||||
{canBookShipment
|
{canBookShipment
|
||||||
? "No bookings yet. Use “New booking” to ship against this contract."
|
? "No bookings yet. Use “New booking” to ship against this contract."
|
||||||
: customsPath
|
: customsPath
|
||||||
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
|
? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it."
|
||||||
: canUploadClearance
|
: canInitiateBooking
|
||||||
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
|
? "No shipments yet. Start one with “Initiate booking” — you upload the clearance documents on that shipment."
|
||||||
: "Bookings appear here once the contract is fully executed."}
|
: "Bookings appear here once the contract is fully executed."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1578,23 +1351,42 @@ export default function ContractDetailPage() {
|
|||||||
{viewer}
|
{viewer}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={clearanceOpen}
|
opened={cancelOpen}
|
||||||
onClose={clearanceModal.close}
|
onClose={() => setCancelOpen(false)}
|
||||||
title={
|
title="Cancel this contract?"
|
||||||
<Text fw={700} fz={16}>
|
|
||||||
{isIntercity ? "Intercity documents" : "Clearance documents"}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
size="xl"
|
|
||||||
radius="md"
|
|
||||||
centered
|
centered
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
||||||
>
|
>
|
||||||
<ContractClearancePanel
|
<Stack gap="md">
|
||||||
contractId={contract.id}
|
<Text size="sm">
|
||||||
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
Contract <b>{contract.reference}</b> will be cancelled. This cannot
|
||||||
bare
|
be undone — but the lane is freed, so you can request a new contract
|
||||||
/>
|
for the same route right away.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason (optional)"
|
||||||
|
placeholder="Why are you cancelling this contract?"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setCancelOpen(false)}>
|
||||||
|
Keep contract
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={cancelContract.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
cancelContract.mutate(cancelReason.trim() || undefined, {
|
||||||
|
onSuccess: () => setCancelOpen(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -1931,292 +1723,3 @@ function FactCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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">
|
|
||||||
{invoiceStatusLabel(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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ function resolveStep(status: string): StepState {
|
|||||||
return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." };
|
return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." };
|
||||||
case "CONTRACT_CLOSED":
|
case "CONTRACT_CLOSED":
|
||||||
case "ARCHIVED":
|
case "ARCHIVED":
|
||||||
return { activeIdx: STAGES.length - 1, terminal: "CLOSED", next: "This contract is closed." };
|
return {
|
||||||
|
activeIdx: STAGES.length - 1,
|
||||||
|
terminal: "CLOSED",
|
||||||
|
next: "This contract is completed — its shipment was delivered.",
|
||||||
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { activeIdx: at("draft"), terminal: null, next: "" };
|
return { activeIdx: at("draft"), terminal: null, next: "" };
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export interface ContractBookingAction {
|
|||||||
* The single source of truth for whether a contract row should show a
|
* The single source of truth for whether a contract row should show a
|
||||||
* Book / Re-book shipment button, and where it should navigate.
|
* Book / Re-book shipment button, and where it should navigate.
|
||||||
*
|
*
|
||||||
* - ONE_TIME: book only while there is no active booking. If the previous
|
* - ONE_TIME: one shipment instance at a time. Import/export self-clearance
|
||||||
* booking expired, offer Re-book. Once a live booking exists, no button.
|
* initiates a bare instance (clearance first); intercity books directly.
|
||||||
|
* Customs is initiated by GL, so the customer gets no button.
|
||||||
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
|
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
|
||||||
* already excluded by the PATH_A_BOOKABLE gate).
|
* already excluded by the PATH_A_BOOKABLE gate).
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +62,8 @@ export function getContractBookingAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other customs (ONE_TIME): booked by GL — no customer action.
|
// Other customs (ONE_TIME): GL initiates the shipment instance and completes
|
||||||
|
// it — the customer only uploads documents on that booking. No action here.
|
||||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
||||||
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
@@ -76,13 +78,24 @@ export function getContractBookingAction(
|
|||||||
const hasLiveSplit = mine.some(
|
const hasLiveSplit = mine.some(
|
||||||
(b) => b.isSplit && !RELEASING_BOOKING_STATUSES.includes(b.status),
|
(b) => b.isSplit && !RELEASING_BOOKING_STATUSES.includes(b.status),
|
||||||
);
|
);
|
||||||
if (hasLiveSplit) return { kind: "book", to, remainderOnly: true };
|
const hasActive =
|
||||||
const hasActive = mine.some(
|
!hasLiveSplit &&
|
||||||
(b) => !TERMINAL_BOOKING_STATUSES.includes(b.status),
|
mine.some((b) => !TERMINAL_BOOKING_STATUSES.includes(b.status));
|
||||||
);
|
|
||||||
if (hasActive) return { kind: "none", to: "" };
|
if (hasActive) return { kind: "none", to: "" };
|
||||||
const hasExpired = mine.some((b) => b.status === "EXPIRED");
|
// Intercity is booked directly with its cargo (no shipment day to defer to);
|
||||||
return { kind: hasExpired ? "rebook" : "book", to };
|
// its documents still live on that booking.
|
||||||
|
if (contract.tradeDirection === "DOMESTIC") {
|
||||||
|
const hasExpired = mine.some((b) => b.status === "EXPIRED");
|
||||||
|
return { kind: hasExpired ? "rebook" : "book", to };
|
||||||
|
}
|
||||||
|
// Import/export self-clearance: one-click bare instance, exactly like a
|
||||||
|
// GENERAL contract — clearance documents are uploaded on it, and cargo +
|
||||||
|
// shipment day come at completion.
|
||||||
|
return {
|
||||||
|
kind: "initiate",
|
||||||
|
to: `/contracts/${contract.id}`,
|
||||||
|
...(hasLiveSplit ? { remainderOnly: true } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GENERAL non-customs import/export: one-click bare booking instance — the
|
// GENERAL non-customs import/export: one-click bare booking instance — the
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
|||||||
CLEARANCE_UNDER_REVIEW: { label: "Clearance Under Review", ...TONE.info },
|
CLEARANCE_UNDER_REVIEW: { label: "Clearance Under Review", ...TONE.info },
|
||||||
CLEARANCE_READY_FOR_BOOKING: { label: "Cleared — Awaiting Booking", ...TONE.success },
|
CLEARANCE_READY_FOR_BOOKING: { label: "Cleared — Awaiting Booking", ...TONE.success },
|
||||||
ACTIVE_SHIPMENT_IN_PROGRESS: { label: "Shipment In Progress", ...TONE.info },
|
ACTIVE_SHIPMENT_IN_PROGRESS: { label: "Shipment In Progress", ...TONE.info },
|
||||||
|
SUSPENDED: { label: "Suspended", ...TONE.warning },
|
||||||
// ── Terminal ──
|
// ── Terminal ──
|
||||||
CONTRACT_CLOSED: { label: "Closed", ...TONE.neutral },
|
CONTRACT_CLOSED: { label: "Completed", ...TONE.neutral },
|
||||||
EXPIRED: { label: "Expired", ...TONE.danger },
|
EXPIRED: { label: "Expired", ...TONE.danger },
|
||||||
REJECTED: { label: "Rejected", ...TONE.danger },
|
REJECTED: { label: "Rejected", ...TONE.danger },
|
||||||
CANCELLED: { label: "Cancelled", ...TONE.danger },
|
CANCELLED: { label: "Cancelled", ...TONE.danger },
|
||||||
|
|||||||
@@ -285,6 +285,15 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel own contract so a fresh one can be requested on the same lane. The
|
||||||
|
* API rejects it while any shipment on the contract is still live.
|
||||||
|
*/
|
||||||
|
cancel: async (id: string, reason?: string): Promise<Freight.IContract> => {
|
||||||
|
const { data } = await client.post(C.CANCEL(id), { reason });
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Pre-booking clearance (Path B) ──
|
// ── Pre-booking clearance (Path B) ──
|
||||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||||
const { data } = await client.get(C.CLEARANCE(id));
|
const { data } = await client.get(C.CLEARANCE(id));
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export const CONTRACT_STATUSES = [
|
|||||||
"CLEARANCE_UNDER_REVIEW",
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
|
// Backoffice froze the contract post-signature. Reversible: the pre-suspension
|
||||||
|
// status is kept in statusBeforeSuspension and restored on resume.
|
||||||
|
"SUSPENDED",
|
||||||
// terminal
|
// terminal
|
||||||
"CONTRACT_CLOSED",
|
"CONTRACT_CLOSED",
|
||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
@@ -768,6 +771,18 @@ export interface IContract extends BaseEntity {
|
|||||||
* Tells the returned-to approver why the chain came back to them.
|
* Tells the returned-to approver why the chain came back to them.
|
||||||
*/
|
*/
|
||||||
latestSendBackNote?: string | null;
|
latestSendBackNote?: string | null;
|
||||||
|
/** Status the contract returns to when a suspension is lifted. */
|
||||||
|
statusBeforeSuspension?: ContractStatus | null;
|
||||||
|
/**
|
||||||
|
* Body of the latest SUSPENSION review note (detail response only, while
|
||||||
|
* SUSPENDED). Shown to both staff and customer.
|
||||||
|
*/
|
||||||
|
latestSuspensionNote?: string | null;
|
||||||
|
/**
|
||||||
|
* Bookings on this contract that are not in a terminal state (detail response
|
||||||
|
* only). The portal's cancel action is blocked while this is > 0.
|
||||||
|
*/
|
||||||
|
activeBookingCount?: number;
|
||||||
clearanceStatus: ContractClearanceStatus;
|
clearanceStatus: ContractClearanceStatus;
|
||||||
clearanceCycleNumber: number;
|
clearanceCycleNumber: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ export enum WagonMovementKind {
|
|||||||
EmptyReposition = "EMPTY_REPOSITION",
|
EmptyReposition = "EMPTY_REPOSITION",
|
||||||
/** Staff manually corrected/updated the wagon's yard. */
|
/** Staff manually corrected/updated the wagon's yard. */
|
||||||
Manual = "MANUAL",
|
Manual = "MANUAL",
|
||||||
|
/** Detached from a train and put out of service for maintenance. */
|
||||||
|
Maintenance = "MAINTENANCE",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWagonMovement extends BaseEntity {
|
export interface IWagonMovement extends BaseEntity {
|
||||||
|
|||||||
76
reset-bookings-trains.sql
Normal file
76
reset-bookings-trains.sql
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
-- edr_dev reset: drop every contract booking, every schedule/built train,
|
||||||
|
-- and hand the whole fleet back as AVAILABLE.
|
||||||
|
-- Deliberately KEPT: contracts, companies, rates, yards, routes, the 5 wagons
|
||||||
|
-- and 7 locomotives parked in MAINTENANCE / OUT_OF_SERVICE, and the single
|
||||||
|
-- contract-less draft booking BK-2026-000011.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Detach the fleet FIRST — wagons point at trains, consists and schedules.
|
||||||
|
UPDATE freight.wagons
|
||||||
|
SET train_id = NULL,
|
||||||
|
current_train_schedule_id = NULL,
|
||||||
|
train_set_wagon_id = NULL,
|
||||||
|
sequence_number = NULL,
|
||||||
|
import_train_number = NULL,
|
||||||
|
export_train_number = NULL,
|
||||||
|
status = 'AVAILABLE',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status <> 'MAINTENANCE';
|
||||||
|
|
||||||
|
UPDATE freight.locomotives
|
||||||
|
SET status = 'AVAILABLE',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status NOT IN ('MAINTENANCE', 'OUT_OF_SERVICE');
|
||||||
|
|
||||||
|
-- 2. Booking-scoped rows (allocations, batch offers, movements, documents).
|
||||||
|
DELETE FROM freight.train_schedule_bookings;
|
||||||
|
DELETE FROM freight.booking_batch_offers;
|
||||||
|
DELETE FROM freight.wagon_booking_allocations;
|
||||||
|
DELETE FROM freight.wagon_allocation_bulk_loads;
|
||||||
|
DELETE FROM freight.booking_container_allocations;
|
||||||
|
DELETE FROM freight.wagon_movements;
|
||||||
|
DELETE FROM freight.customer_truck_containers;
|
||||||
|
DELETE FROM freight.customer_truck_assignments;
|
||||||
|
DELETE FROM freight.interchange_document_items;
|
||||||
|
DELETE FROM freight.facility_handling_events;
|
||||||
|
DELETE FROM freight.booking_handovers;
|
||||||
|
DELETE FROM freight.clearance_incidents;
|
||||||
|
DELETE FROM freight.clearance_milestones WHERE booking_id IS NOT NULL;
|
||||||
|
DELETE FROM freight.booking_document_review;
|
||||||
|
DELETE FROM freight.booking_contract_signatures;
|
||||||
|
DELETE FROM freight.booking_review_note;
|
||||||
|
DELETE FROM freight.booking_cargo_modifier;
|
||||||
|
DELETE FROM freight.booking_rate_snapshot;
|
||||||
|
DELETE FROM freight.containers WHERE booking_id IS NOT NULL;
|
||||||
|
DELETE FROM freight.booking_container;
|
||||||
|
DELETE FROM freight.cargoes WHERE booking_id IS NOT NULL;
|
||||||
|
DELETE FROM freight.first_mile;
|
||||||
|
DELETE FROM freight.last_mile;
|
||||||
|
DELETE FROM freight.booking_requests;
|
||||||
|
|
||||||
|
-- 3. Billing raised for those bookings.
|
||||||
|
DELETE FROM freight.invoice_lines
|
||||||
|
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'booking');
|
||||||
|
UPDATE freight.invoices SET payment_id = NULL WHERE source = 'booking';
|
||||||
|
DELETE FROM freight.payment_refunds;
|
||||||
|
DELETE FROM freight.invoices WHERE source = 'booking';
|
||||||
|
DELETE FROM freight.payments;
|
||||||
|
|
||||||
|
-- 4. The bookings themselves (self-FK cleared first).
|
||||||
|
UPDATE freight.bookings SET consolidation_partner_id = NULL WHERE contract_id IS NOT NULL;
|
||||||
|
DELETE FROM freight.bookings WHERE contract_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 5. Schedules, built trains and consists.
|
||||||
|
DELETE FROM freight.train_checkpoint_events;
|
||||||
|
DELETE FROM freight.djibouti_import_incidents;
|
||||||
|
DELETE FROM freight.import_djibouti_operations;
|
||||||
|
DELETE FROM freight.train_schedules;
|
||||||
|
DELETE FROM freight.train_set_wagons;
|
||||||
|
DELETE FROM freight.train_set_locomotives;
|
||||||
|
DELETE FROM freight.train_sets;
|
||||||
|
DELETE FROM freight.train_locomotives;
|
||||||
|
DELETE FROM freight.trains;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user