mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Merge pull request #717 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepaid customs clearance service fee (Path B):
|
||||||
|
* - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
|
||||||
|
* fee line so it is billed via its own clearance invoice and excluded from
|
||||||
|
* shipment booking totals;
|
||||||
|
* - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
|
||||||
|
* settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
|
||||||
|
* - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
|
||||||
|
* fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
|
||||||
|
* All nullable/defaulted — existing rows are untouched and keep today's flow.
|
||||||
|
*/
|
||||||
|
export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_rate_snapshots
|
||||||
|
ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contract_rate_snapshots
|
||||||
|
DROP COLUMN IF EXISTS is_clearance;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -714,6 +715,11 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||||
|
throw new ConflictException(
|
||||||
|
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
||||||
|
);
|
||||||
|
}
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
|
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY',
|
'CLEARANCE_READY',
|
||||||
@@ -521,6 +522,10 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
clearanceCurrentPhase?: string | null;
|
clearanceCurrentPhase?: string | null;
|
||||||
|
|
||||||
|
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
||||||
|
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
clearanceFeePaidAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { ContractPricingBreakdown } from './contract-pricing.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
||||||
|
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
||||||
|
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
||||||
|
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
||||||
|
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
||||||
|
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
||||||
|
* document step opens and before GL touches the file:
|
||||||
|
* - ONE_TIME: once per contract, at staff counter-sign
|
||||||
|
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
||||||
|
* - GENERAL: once per shipment request, on the initiated booking instance
|
||||||
|
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
||||||
|
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
||||||
|
* customers pay what their contract shows, not the live rate of the day.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ClearanceFeeService {
|
||||||
|
private readonly logger = new Logger(ClearanceFeeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
||||||
|
private async feeAmountOrNull(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<{ amount: number; currency: string } | null> {
|
||||||
|
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
||||||
|
const snapshot = snapshots.find(
|
||||||
|
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
||||||
|
);
|
||||||
|
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
||||||
|
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
||||||
|
}
|
||||||
|
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||||
|
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
if (line && Number(line.unitPrice) > 0) {
|
||||||
|
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async feeAmount(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<{ amount: number; currency: string }> {
|
||||||
|
const fee = await this.feeAmountOrNull(contract);
|
||||||
|
if (!fee) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the payment gate applies. Skipped for government/unlinked
|
||||||
|
* contracts (no company to bill — invoices require one, same rule the
|
||||||
|
* booking invoice applies) and for legacy customs contracts frozen before
|
||||||
|
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
||||||
|
* the pre-fee flow instead of dead-ending.
|
||||||
|
*/
|
||||||
|
async gateApplies(contract: Contract): Promise<boolean> {
|
||||||
|
if (!contract.customsClearingEnabled || !contract.companyId) return false;
|
||||||
|
if ((await this.feeAmountOrNull(contract)) !== null) return true;
|
||||||
|
this.logger.warn(
|
||||||
|
`Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||||
|
async issueForContract(contract: Contract): Promise<Invoice> {
|
||||||
|
const existing = await this.billing.findPayable(
|
||||||
|
Freight.InvoiceSource.Clearance,
|
||||||
|
contract.id,
|
||||||
|
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const { amount, currency } = await this.feeAmount(contract);
|
||||||
|
const invoice = await this.billing.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.Clearance,
|
||||||
|
sourceId: contract.id,
|
||||||
|
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||||
|
companyId: contract.companyId!,
|
||||||
|
companyProfileId: contract.companyProfileId!,
|
||||||
|
currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: `Customs clearance service fee — contract ${contract.reference}`,
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: amount,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
});
|
||||||
|
this.notifier.clearanceFeeDue(contract, amount, currency);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
||||||
|
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
||||||
|
const existing = await this.billing.findPayable(
|
||||||
|
Freight.InvoiceSource.Clearance,
|
||||||
|
booking.id,
|
||||||
|
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const { amount, currency } = await this.feeAmount(contract);
|
||||||
|
const invoice = await this.billing.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.Clearance,
|
||||||
|
sourceId: booking.id,
|
||||||
|
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||||
|
companyId: booking.companyId ?? contract.companyId!,
|
||||||
|
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
||||||
|
currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: amount,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
});
|
||||||
|
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settlement branch point for `clearance`-source invoices: unlock the
|
||||||
|
* document-upload step the fee was gating. Idempotent — a replayed event on
|
||||||
|
* an already-advanced contract/booking is a no-op.
|
||||||
|
*/
|
||||||
|
@OnEvent('clearance.invoice.paid')
|
||||||
|
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
this.logger.log(
|
||||||
|
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||||
|
);
|
||||||
|
switch (payload.type) {
|
||||||
|
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
||||||
|
await this.advanceContract(payload.sourceId);
|
||||||
|
break;
|
||||||
|
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
||||||
|
await this.advanceBooking(payload.sourceId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceContract(contractId: string): Promise<void> {
|
||||||
|
const contract = await this.contractsRepository.findById(contractId);
|
||||||
|
if (!contract) {
|
||||||
|
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||||
|
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||||
|
clearanceFeePaidAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (updated) this.notifier.clearanceFeePaid(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async advanceBooking(bookingId: string): Promise<void> {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'AWAITING_DOCUMENTS',
|
||||||
|
clearanceFeePaidAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
if (booking.contractId) {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||||
|
booking.contractId,
|
||||||
|
);
|
||||||
|
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // milestoneService
|
{} as never, // milestoneService
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
|
{} as never, // clearanceFeeService
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
|
{} as never, // clearanceFeeService
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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 } from './contracts.repository';
|
||||||
|
import { ClearanceFeeService } from './clearance-fee.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
@@ -78,6 +79,7 @@ export class ContractBookingService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
|
private readonly clearanceFeeService: ClearanceFeeService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
@@ -492,6 +494,11 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
|
// Prepay gate: each shipment request owes its own flat clearance service
|
||||||
|
// fee before the document step opens (the paid event advances the booking
|
||||||
|
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
||||||
|
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
||||||
|
|
||||||
const booking = await insertWithGeneratedReference(
|
const booking = await insertWithGeneratedReference(
|
||||||
() => this.generateReference(),
|
() => this.generateReference(),
|
||||||
(reference) =>
|
(reference) =>
|
||||||
@@ -501,7 +508,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: 'AWAITING_DOCUMENTS',
|
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -540,6 +547,10 @@ export class ContractBookingService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (feeGate) {
|
||||||
|
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||||
|
}
|
||||||
|
|
||||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -489,6 +489,11 @@ export class ContractClearanceService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||||
|
|||||||
@@ -158,6 +158,26 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clearance service fee invoiced — customer must pay before document upload. */
|
||||||
|
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
||||||
|
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||||
|
const msg =
|
||||||
|
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
||||||
|
`Please pay from the portal to unlock the clearance document upload.`;
|
||||||
|
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
||||||
|
this.inApp(c, 'Clearance fee due', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clearance service fee settled — document upload is now open. */
|
||||||
|
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
||||||
|
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||||
|
const msg =
|
||||||
|
`Your customs clearance service fee for ${scope} has been received. ` +
|
||||||
|
`You can now upload the clearance documents from the portal.`;
|
||||||
|
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
||||||
|
this.inApp(c, 'Clearance fee paid', msg);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clearance milestones needing customer action ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
|
|||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
cargoTypeCode?: string | null;
|
cargoTypeCode?: string | null;
|
||||||
|
/**
|
||||||
|
* Customs clearance service fee — billed separately in advance (before the
|
||||||
|
* clearance document step), never part of shipment booking totals.
|
||||||
|
*/
|
||||||
|
isClearance?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||||
@@ -184,6 +189,31 @@ export class ContractPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||||
|
// contract and billed via its own clearance invoice: after counter-sign for
|
||||||
|
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||||
|
// A customs contract may not proceed without a configured live rate.
|
||||||
|
if (contract.customsClearingEnabled) {
|
||||||
|
const clearance = liveRates.find(
|
||||||
|
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
label:
|
||||||
|
contract.contractKind === 'GENERAL'
|
||||||
|
? 'Customs clearance service fee (per shipment request, prepaid)'
|
||||||
|
: 'Customs clearance service fee (prepaid)',
|
||||||
|
unit: toContractUnit(clearance.rateUnit),
|
||||||
|
unitPrice: convert(Number(clearance.rateValue)),
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayMode: 'UNIT_RATES',
|
displayMode: 'UNIT_RATES',
|
||||||
currency,
|
currency,
|
||||||
@@ -229,6 +259,7 @@ export class ContractPricingService {
|
|||||||
containerSize: line.containerSize ?? null,
|
containerSize: line.containerSize ?? null,
|
||||||
isSurcharge: !!line.conditionalOn,
|
isSurcharge: !!line.conditionalOn,
|
||||||
conditionalOn: line.conditionalOn ?? null,
|
conditionalOn: line.conditionalOn ?? null,
|
||||||
|
isClearance: !!line.isClearance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service';
|
|||||||
import { OtpService } from '../otp/otp.service';
|
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 { ClearanceFeeService } from './clearance-fee.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -89,6 +90,7 @@ export class ContractTransitionService {
|
|||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly contractTemplates: ContractTemplatesService,
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
|
private readonly clearanceFeeService: ClearanceFeeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -866,8 +868,17 @@ export class ContractTransitionService {
|
|||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
// Path B prepay gate: the customs clearance service fee is invoiced here
|
||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
// and must settle before the document step opens (the paid event advances
|
||||||
|
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
||||||
|
if (await this.clearanceFeeService.gateApplies(contract)) {
|
||||||
|
await this.clearanceFeeService.issueForContract(contract);
|
||||||
|
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
||||||
|
} else {
|
||||||
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||||
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
|
}
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ClearanceFeeService } from './clearance-fee.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
|
ClearanceFeeService,
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
|
|||||||
@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
|
|||||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance service fee line — billed up front via a clearance
|
||||||
|
* invoice, excluded from shipment booking totals.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||||
|
isClearance!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
|
|||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
|
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
@@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
|||||||
|
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
'NOT_APPLICABLE',
|
'NOT_APPLICABLE',
|
||||||
|
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||||
@@ -215,6 +217,10 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||||
clearanceCycleNumber!: number;
|
clearanceCycleNumber!: number;
|
||||||
|
|
||||||
|
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
||||||
|
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
clearanceFeePaidAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
pricingBreakdown?: Record<string, unknown> | null;
|
pricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -24,6 +25,23 @@ export class PriorityConfigsController {
|
|||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static route — must stay above `:id` (Express matches in declaration order).
|
||||||
|
@Get('next-range')
|
||||||
|
@RuleEngineView('priority-configs')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
|
||||||
|
})
|
||||||
|
nextRange(
|
||||||
|
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||||
|
@Query('currency') currency?: string,
|
||||||
|
) {
|
||||||
|
if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) {
|
||||||
|
throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS');
|
||||||
|
}
|
||||||
|
return this.service.nextRange(type, currency ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('priority-configs')
|
@RuleEngineView('priority-configs')
|
||||||
@ApiOperation({ summary: 'Get a priority config by ID' })
|
@ApiOperation({ summary: 'Get a priority config by ID' })
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function deriveRateType(input: {
|
|||||||
return 'DEMURRAGE';
|
return 'DEMURRAGE';
|
||||||
case 'PIL_EXTRA_FEE':
|
case 'PIL_EXTRA_FEE':
|
||||||
return 'PIL_EXTRA_FEE';
|
return 'PIL_EXTRA_FEE';
|
||||||
|
case 'CUSTOMS_CLEARANCE':
|
||||||
|
return 'CUSTOMS_CLEARANCE';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export function allowedRateUnits(input: {
|
|||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
|
case 'CUSTOMS_CLEARANCE':
|
||||||
|
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
||||||
|
return ['FLAT'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const RATE_TYPES = [
|
|||||||
'HAZARD_SURCHARGE',
|
'HAZARD_SURCHARGE',
|
||||||
'REEFER_SURCHARGE',
|
'REEFER_SURCHARGE',
|
||||||
'PIL_EXTRA_FEE',
|
'PIL_EXTRA_FEE',
|
||||||
|
'CUSTOMS_CLEARANCE',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type RateType = typeof RATE_TYPES[number];
|
export type RateType = typeof RATE_TYPES[number];
|
||||||
@@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [
|
|||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
'DEMURRAGE',
|
'DEMURRAGE',
|
||||||
'PIL_EXTRA_FEE',
|
'PIL_EXTRA_FEE',
|
||||||
|
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||||
|
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||||
|
'CUSTOMS_CLEARANCE',
|
||||||
] as const;
|
] as const;
|
||||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||||
|
import { PriorityConfigsService } from './priority-configs.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contiguous-range rules for priority configs: per type (per currency for
|
||||||
|
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
|
||||||
|
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
||||||
|
* CURRENCY 35, CUSTOMS 15.
|
||||||
|
*/
|
||||||
|
describe('PriorityConfigsService range validation', () => {
|
||||||
|
const rule = (
|
||||||
|
type: PriorityConfig['type'],
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
currency: string | null = null,
|
||||||
|
id = `${type}-${min}-${max}-${currency ?? 'none'}`,
|
||||||
|
): PriorityConfig =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label: `${min}-${max}`,
|
||||||
|
currency,
|
||||||
|
minWagonCount: min,
|
||||||
|
maxWagonCount: max,
|
||||||
|
}) as PriorityConfig;
|
||||||
|
|
||||||
|
const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => {
|
||||||
|
const repository = {
|
||||||
|
findAll: jest.fn(async ({ where }: { where: { type: string } }) =>
|
||||||
|
rules.filter((r) => r.type === where.type),
|
||||||
|
),
|
||||||
|
findById: jest.fn(async (id: string) =>
|
||||||
|
rules.find((r) => r.id === id) ?? null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return new PriorityConfigsService(
|
||||||
|
repository as never,
|
||||||
|
undefined as never, // DisplayOrderService — unused by range validation
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attempt = (
|
||||||
|
svc: PriorityConfigsService,
|
||||||
|
input: Partial<Parameters<PriorityConfigsService['assertNoRangeCollision']>[0]>,
|
||||||
|
) =>
|
||||||
|
svc.assertNoRangeCollision({
|
||||||
|
type: 'WAGON',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 5,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the first WAGON range starting at 1', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a first range that does not start at 1', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an exact duplicate (1–5 vs 1–5)', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a partial overlap (4–7 after 1–5)', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||||
|
minWagonCount: 4,
|
||||||
|
maxWagonCount: 7,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||||
|
minWagonCount: 8,
|
||||||
|
maxWagonCount: 9,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the contiguous continuation (6–10 after 1–5)', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||||
|
minWagonCount: 6,
|
||||||
|
maxWagonCount: 10,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after deleting a middle rule, the next range must fill the lowest gap', async () => {
|
||||||
|
// Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6.
|
||||||
|
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||||
|
await expect(
|
||||||
|
attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
await expect(
|
||||||
|
attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => {
|
||||||
|
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||||
|
await expect(
|
||||||
|
attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }),
|
||||||
|
).rejects.toThrow(/overlaps existing rule/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
||||||
|
).rejects.toThrow(/may not exceed 50/);
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([]), {
|
||||||
|
type: 'CURRENCY',
|
||||||
|
currency: 'USD',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 36,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/may not exceed 35/);
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([]), {
|
||||||
|
type: 'CUSTOMS',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 16,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/may not exceed 15/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects any new rule once the chain covers the full range', async () => {
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||||||
|
minWagonCount: 51,
|
||||||
|
maxWagonCount: 51,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/may not exceed 50/);
|
||||||
|
await expect(
|
||||||
|
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||||||
|
type: 'CUSTOMS',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/already cover the full 1–15 range/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
|
||||||
|
const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]);
|
||||||
|
// ETB has no rules yet → starts at 1.
|
||||||
|
await expect(
|
||||||
|
attempt(svc, {
|
||||||
|
type: 'CURRENCY',
|
||||||
|
currency: 'ETB',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 5,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// USD must continue at 6.
|
||||||
|
await expect(
|
||||||
|
attempt(svc, {
|
||||||
|
type: 'CURRENCY',
|
||||||
|
currency: 'USD',
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 5,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the rule being edited from its own contiguity check', async () => {
|
||||||
|
const existing = rule('WAGON', 6, 10, null, 'editing-me');
|
||||||
|
const svc = serviceWith([rule('WAGON', 1, 5), existing]);
|
||||||
|
// Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed.
|
||||||
|
await expect(
|
||||||
|
attempt(svc, {
|
||||||
|
minWagonCount: 6,
|
||||||
|
maxWagonCount: 12,
|
||||||
|
excludeId: 'editing-me',
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an upper rule keep its start while a lower gap exists', async () => {
|
||||||
|
// Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap
|
||||||
|
// must not block editing an upper rule's points or max.
|
||||||
|
const upper = rule('WAGON', 11, 20, null, 'upper');
|
||||||
|
const svc = serviceWith([rule('WAGON', 1, 5), upper]);
|
||||||
|
await expect(
|
||||||
|
attempt(svc, {
|
||||||
|
minWagonCount: 11,
|
||||||
|
maxWagonCount: 25,
|
||||||
|
excludeId: 'upper',
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6.
|
||||||
|
await expect(
|
||||||
|
attempt(svc, {
|
||||||
|
minWagonCount: 30,
|
||||||
|
maxWagonCount: 35,
|
||||||
|
excludeId: 'upper',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/must start at 6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the next-range prefill for the form', async () => {
|
||||||
|
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||||
|
await expect(svc.nextRange('WAGON')).resolves.toEqual({
|
||||||
|
nextMin: 6,
|
||||||
|
maxCap: 50,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
||||||
|
).resolves.toEqual({ nextMin: null, maxCap: 15 });
|
||||||
|
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||||||
|
nextMin: 1,
|
||||||
|
maxCap: 35,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,31 @@ import {
|
|||||||
} from '../interfaces/priority-configs.repository.interface';
|
} from '../interfaces/priority-configs.repository.interface';
|
||||||
import { DisplayOrderService } from './display-order.service';
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
|
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
|
||||||
|
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
|
||||||
|
WAGON: 50,
|
||||||
|
CURRENCY: 35,
|
||||||
|
CUSTOMS: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
|
||||||
|
* must start. Null when the chain is already complete up to the type's cap.
|
||||||
|
*/
|
||||||
|
function nextRangeStart(
|
||||||
|
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||||
|
): number | null {
|
||||||
|
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
|
||||||
|
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
||||||
|
let next = 1;
|
||||||
|
for (const r of sorted) {
|
||||||
|
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
||||||
|
next = Math.max(next, r.maxWagonCount + 1);
|
||||||
|
}
|
||||||
|
if (cap != null && next > cap) return null;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PriorityConfigsService {
|
export class PriorityConfigsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -73,10 +98,13 @@ export class PriorityConfigsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No two rules of the same type (and, for CURRENCY rules, the same currency)
|
* Range rules per type (and, for CURRENCY rules, per currency):
|
||||||
* may cover overlapping wagon-count ranges — a booking must match at most one
|
* - ranges never overlap — a booking matches at most one rule per type;
|
||||||
* rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
|
* - ranges are contiguous from 1: a new range must START at the lowest
|
||||||
* overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
|
* wagon count not yet covered (after 1–5 the next is 6–…; deleting a
|
||||||
|
* middle rule opens a gap and the next create must fill it first);
|
||||||
|
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
|
||||||
|
* Ranges are inclusive on both ends.
|
||||||
*/
|
*/
|
||||||
async assertNoRangeCollision(input: {
|
async assertNoRangeCollision(input: {
|
||||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||||
@@ -90,13 +118,49 @@ export class PriorityConfigsService {
|
|||||||
'Min wagon count cannot be greater than max wagon count',
|
'Min wagon count cannot be greater than max wagon count',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const siblings = await this.repository.findAll({
|
const cap = RANGE_CAPS[input.type];
|
||||||
where: { type: input.type },
|
if (input.maxWagonCount > cap) {
|
||||||
});
|
throw new BadRequestException(
|
||||||
const clash = siblings.find(
|
`${input.type} ranges may not exceed ${cap} — ` +
|
||||||
|
`${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = (
|
||||||
|
await this.repository.findAll({ where: { type: input.type } })
|
||||||
|
).filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.id !== input.excludeId &&
|
s.id !== input.excludeId &&
|
||||||
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
|
(input.type !== 'CURRENCY' ||
|
||||||
|
(s.currency ?? null) === (input.currency ?? null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedStart = nextRangeStart(siblings);
|
||||||
|
// An edited rule may always KEEP its current start (so a gap lower in the
|
||||||
|
// chain never blocks editing an upper rule's points/max) — or move down to
|
||||||
|
// fill that lowest gap.
|
||||||
|
const currentStart = input.excludeId
|
||||||
|
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
||||||
|
: null;
|
||||||
|
if (expectedStart == null && currentStart == null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${input.type} rules already cover the full 1–${cap} range — ` +
|
||||||
|
'delete or shrink an existing rule first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.minWagonCount !== expectedStart &&
|
||||||
|
input.minWagonCount !== currentStart
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The next ${input.type} range must start at ${expectedStart} ` +
|
||||||
|
`(ranges are contiguous — no gaps, no overlaps). ` +
|
||||||
|
`You entered ${input.minWagonCount}–${input.maxWagonCount}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clash = siblings.find(
|
||||||
|
(s) =>
|
||||||
input.minWagonCount <= s.maxWagonCount &&
|
input.minWagonCount <= s.maxWagonCount &&
|
||||||
input.maxWagonCount >= s.minWagonCount,
|
input.maxWagonCount >= s.minWagonCount,
|
||||||
);
|
);
|
||||||
@@ -109,6 +173,24 @@ export class PriorityConfigsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the next range for a type/currency must start, and the type's
|
||||||
|
* ceiling — feeds the create form so the min field is auto-filled and
|
||||||
|
* locked. `nextMin` is null when the chain already covers 1..cap.
|
||||||
|
*/
|
||||||
|
async nextRange(
|
||||||
|
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||||
|
currency?: string | null,
|
||||||
|
): Promise<{ nextMin: number | null; maxCap: number }> {
|
||||||
|
const siblings = (
|
||||||
|
await this.repository.findAll({ where: { type } })
|
||||||
|
).filter(
|
||||||
|
(s) =>
|
||||||
|
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
|
||||||
|
);
|
||||||
|
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a built train's display identity: its name and its fixed import/export
|
||||||
|
* run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
|
||||||
|
* Omitted fields keep their current value; an empty trainName clears the name.
|
||||||
|
*/
|
||||||
|
export class UpdateTrainDetailsDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Display name; empty string clears it' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
trainName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
|
importTrainNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
|
exportTrainNumber?: string;
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
|
|||||||
trainNumber?: string;
|
trainNumber?: string;
|
||||||
|
|
||||||
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
|
||||||
trainName?: string;
|
trainName?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
||||||
routeId?: string;
|
routeId?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
|||||||
import { BuildTrainDto } from './dto/build-train.dto';
|
import { BuildTrainDto } from './dto/build-train.dto';
|
||||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||||
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||||
import { TrainBuilderService } from './train-builder.service';
|
import { TrainBuilderService } from './train-builder.service';
|
||||||
@@ -59,6 +60,18 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.setLocomotives(id, dto);
|
return this.trainBuilderService.setLocomotives(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/details')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Edit the train's name and fixed import/export run numbers",
|
||||||
|
})
|
||||||
|
updateDetails(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateTrainDetailsDto,
|
||||||
|
) {
|
||||||
|
return this.trainBuilderService.updateDetails(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/yard')
|
@Patch(':id/yard')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
|||||||
import { BuildTrainDto } from './dto/build-train.dto';
|
import { BuildTrainDto } from './dto/build-train.dto';
|
||||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||||
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||||
import { Train } from './entities/train.entity';
|
import { Train } from './entities/train.entity';
|
||||||
@@ -331,6 +332,53 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a built train's display identity: name and fixed import/export run
|
||||||
|
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||||
|
* with any other train's pair or legacy number (friendly 409 ahead of the
|
||||||
|
* partial unique indexes). Blocked while the train is out on a dispatched
|
||||||
|
* run, like every other composition edit.
|
||||||
|
*/
|
||||||
|
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const train = await this.getEditableTrain(manager, id);
|
||||||
|
|
||||||
|
const patch: Partial<Train> = {};
|
||||||
|
if (dto.trainName !== undefined) {
|
||||||
|
patch.trainName = dto.trainName.trim() || null;
|
||||||
|
}
|
||||||
|
const importTrainNumber = dto.importTrainNumber?.trim();
|
||||||
|
const exportTrainNumber = dto.exportTrainNumber?.trim();
|
||||||
|
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
|
||||||
|
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
|
||||||
|
|
||||||
|
if (importTrainNumber || exportTrainNumber) {
|
||||||
|
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
|
||||||
|
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
|
||||||
|
const numberClash: { code: string }[] = await manager.query(
|
||||||
|
`SELECT code FROM freight.trains
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND id != $3
|
||||||
|
AND (import_train_number IN ($1, $2)
|
||||||
|
OR export_train_number IN ($1, $2)
|
||||||
|
OR train_number IN ($1, $2))
|
||||||
|
LIMIT 1`,
|
||||||
|
[nextImport, nextExport, train.id],
|
||||||
|
);
|
||||||
|
if (numberClash.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
await manager.getRepository(Train).update(train.id, patch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.getComposition(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relocate the train to another yard. The consist moves as one unit: every
|
* Relocate the train to another yard. The consist moves as one unit: every
|
||||||
* coupled locomotive and wagon follows to the new yard (so their current
|
* coupled locomotive and wagon follows to the new yard (so their current
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
FileCheck,
|
||||||
FilePen,
|
FilePen,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -180,7 +180,7 @@ export function ContractActionsToolbar({
|
|||||||
documentGenerated ? (
|
documentGenerated ? (
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles size={16} />
|
<FileCheck size={16} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loading={mutations.generateContract.isPending}
|
loading={mutations.generateContract.isPending}
|
||||||
@@ -196,7 +196,7 @@ export function ContractActionsToolbar({
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
leftSection={<Sparkles size={16} />}
|
leftSection={<FileCheck size={16} />}
|
||||||
loading={mutations.generateContract.isPending}
|
loading={mutations.generateContract.isPending}
|
||||||
onClick={() => mutations.generateContract.mutate()}
|
onClick={() => mutations.generateContract.mutate()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, ShieldCheck, X } from "lucide-react";
|
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
@@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingStep, setPendingStep] =
|
const [pendingStep, setPendingStep] =
|
||||||
useState<Freight.IContractApprovalStep | null>(null);
|
useState<Freight.IContractApprovalStep | null>(null);
|
||||||
|
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectStepRow, setRejectStepRow] =
|
const [rejectStepRow, setRejectStepRow] =
|
||||||
useState<Freight.IContractApprovalStep | null>(null);
|
useState<Freight.IContractApprovalStep | null>(null);
|
||||||
@@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({
|
|||||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||||
|
|
||||||
|
// Approvers must review the GENERATED contract document before approving. If
|
||||||
|
// it has not been generated yet, block the approval and tell staff to generate
|
||||||
|
// it first (via "Generate contract" in Staff actions) — mirrors the server
|
||||||
|
// guard so the user sees a clear reason, not a generic failure toast.
|
||||||
|
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||||
|
|
||||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||||
|
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
|
||||||
|
setNeedsGenerateOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPendingStep(step);
|
setPendingStep(step);
|
||||||
setConfirmOpen(true);
|
setConfirmOpen(true);
|
||||||
};
|
};
|
||||||
@@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={needsGenerateOpen}
|
||||||
|
onClose={() => setNeedsGenerateOpen(false)}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text fw={700}>Generate the contract first</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
The contract document for{" "}
|
||||||
|
<Text span fw={600} c="dark">
|
||||||
|
{contract.reference}
|
||||||
|
</Text>{" "}
|
||||||
|
has not been generated yet. Approvers must review the generated
|
||||||
|
document before it can be approved.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Use{" "}
|
||||||
|
<Text span fw={600} c="dark">
|
||||||
|
Generate contract
|
||||||
|
</Text>{" "}
|
||||||
|
in the Staff actions panel — edit the articles first if needed — then
|
||||||
|
return here to approve.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<FileCheck size={16} />}
|
||||||
|
onClick={() => setNeedsGenerateOpen(false)}
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={rejectOpen}
|
opened={rejectOpen}
|
||||||
onClose={closeReject}
|
onClose={closeReject}
|
||||||
|
|||||||
@@ -201,7 +201,10 @@ const RuleEngineFormDialog = ({
|
|||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const field of visibleFields) {
|
for (const field of visibleFields) {
|
||||||
const raw = values[field.name];
|
// Derived fields always submit their computed value — never stale state.
|
||||||
|
const raw = field.computeValue
|
||||||
|
? (field.computeValue(values) ?? "")
|
||||||
|
: values[field.name];
|
||||||
if (field.type === "multiselect") {
|
if (field.type === "multiselect") {
|
||||||
// Always the full replacement list — the API syncs the relation to it.
|
// Always the full replacement list — the API syncs the relation to it.
|
||||||
payload[field.name] = Array.isArray(raw) ? raw : [];
|
payload[field.name] = Array.isArray(raw) ? raw : [];
|
||||||
@@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isNumber = field.type === "number";
|
const isNumber = field.type === "number";
|
||||||
|
const computed = field.computeValue ? field.computeValue(values) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -359,7 +363,8 @@ const RuleEngineFormDialog = ({
|
|||||||
// display order) is a non-negative magnitude — reject negatives outright
|
// display order) is a non-negative magnitude — reject negatives outright
|
||||||
// rather than letting a typed "-" reach the API.
|
// rather than letting a typed "-" reach the API.
|
||||||
min={isNumber ? 0 : undefined}
|
min={isNumber ? 0 : undefined}
|
||||||
value={String(values[field.name] ?? "")}
|
disabled={field.disabled || computed !== undefined}
|
||||||
|
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.currentTarget.value;
|
const next = e.currentTarget.value;
|
||||||
if (isNumber && next.trim().startsWith("-")) return;
|
if (isNumber && next.trim().startsWith("-")) return;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
|
||||||
|
|
||||||
|
export interface EditTrainDetailsModalProps {
|
||||||
|
/** Train being edited; null closes the modal. */
|
||||||
|
train: BuiltTrainSummary | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a built train's display identity from the list: its name and its fixed
|
||||||
|
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
|
||||||
|
* on the detail page. Number collisions come back as a 409 with the owning
|
||||||
|
* train's code and surface verbatim.
|
||||||
|
*/
|
||||||
|
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [importNo, setImportNo] = useState("");
|
||||||
|
const [exportNo, setExportNo] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (train) {
|
||||||
|
setName(train.trainName ?? "");
|
||||||
|
setImportNo(train.importTrainNumber ?? "");
|
||||||
|
setExportNo(train.exportTrainNumber ?? "");
|
||||||
|
}
|
||||||
|
}, [train]);
|
||||||
|
|
||||||
|
const update = useMutation(api.trainBuilder.updateDetails.mutationOptions());
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!train) return;
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: train.id,
|
||||||
|
payload: {
|
||||||
|
trainName: name.trim(),
|
||||||
|
// Numbers cannot be cleared — only replaced; empty inputs keep the
|
||||||
|
// current value (legacy trains may have none yet).
|
||||||
|
...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}),
|
||||||
|
...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast({ title: `Train ${train.code} updated` });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Update failed";
|
||||||
|
toast({
|
||||||
|
title: "Could not update train",
|
||||||
|
description: String(message),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(train)}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<Pencil size={16} />
|
||||||
|
<Text fw={700}>Edit train {train?.code ?? ""}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Train name"
|
||||||
|
placeholder="Optional display name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.currentTarget.value)}
|
||||||
|
maxLength={100}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Import train no."
|
||||||
|
placeholder="e.g. 8002"
|
||||||
|
value={importNo}
|
||||||
|
onChange={(e) => setImportNo(e.currentTarget.value)}
|
||||||
|
maxLength={20}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Export train no."
|
||||||
|
placeholder="e.g. 8001"
|
||||||
|
value={exportNo}
|
||||||
|
onChange={(e) => setExportNo(e.currentTarget.value)}
|
||||||
|
maxLength={20}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={update.isPending}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditTrainDetailsModal;
|
||||||
@@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [
|
|||||||
{
|
{
|
||||||
key: "clearance",
|
key: "clearance",
|
||||||
label: "Clearance",
|
label: "Clearance",
|
||||||
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
|
statuses: [
|
||||||
|
"AWAITING_CLEARANCE_PAYMENT",
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "payment",
|
key: "payment",
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Active",
|
label: "Active",
|
||||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: {
|
||||||
|
label: "Clearance Fee Due",
|
||||||
|
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
|
},
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Awaiting Documents",
|
label: "Awaiting Documents",
|
||||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
@@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
|||||||
SIGNED_CUSTOMER: "cyan",
|
SIGNED_CUSTOMER: "cyan",
|
||||||
FULLY_EXECUTED: "indigo",
|
FULLY_EXECUTED: "indigo",
|
||||||
CONTRACT_ACTIVE: "edr-green",
|
CONTRACT_ACTIVE: "edr-green",
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: "orange",
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||||
@@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-[color:var(--freight-brand)]",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 3,
|
stage: 3,
|
||||||
},
|
},
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: {
|
||||||
|
title: "Clearance Fee Due",
|
||||||
|
description:
|
||||||
|
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
||||||
|
color: "text-orange-600",
|
||||||
|
stage: 3,
|
||||||
|
},
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
title: "Awaiting Documents",
|
title: "Awaiting Documents",
|
||||||
description: "Customer is uploading pre-booking clearance documents.",
|
description: "Customer is uploading pre-booking clearance documents.",
|
||||||
|
|||||||
@@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
|||||||
/**
|
/**
|
||||||
* Priority-rule approval workflow. Every create/update/delete of a priority
|
* Priority-rule approval workflow. Every create/update/delete of a priority
|
||||||
* config is SUBMITTED as a change request; an approver applies or rejects it.
|
* config is SUBMITTED as a change request; an approver applies or rejects it.
|
||||||
* Error toasts surface the backend message so range-collision rejections
|
* Backend messages (range collision, gap, ceiling) surface verbatim — through
|
||||||
* ("1–5 overlaps existing rule …") reach the user verbatim.
|
* `onErrorMessage` (the page shows them in a modal) or a toast as fallback.
|
||||||
*/
|
*/
|
||||||
export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
export const usePriorityRuleWorkflow = (
|
||||||
|
enabled: boolean,
|
||||||
|
onErrorMessage?: (message: string) => void,
|
||||||
|
) => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const backendMessage = (err: unknown, fallback: string) => {
|
const backendMessage = (err: unknown, fallback: string) => {
|
||||||
@@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
|||||||
return msg || fallback;
|
return msg || fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showError = (err: unknown, fallback: string) => {
|
||||||
|
const message = backendMessage(err, fallback);
|
||||||
|
if (onErrorMessage) onErrorMessage(message);
|
||||||
|
else toast.error(message);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = useQuery({
|
const pending = useQuery({
|
||||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||||
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
|
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
|
||||||
@@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
|||||||
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
|
||||||
});
|
});
|
||||||
await invalidateRuleEngineList(qc, "priority-configs");
|
await invalidateRuleEngineList(qc, "priority-configs");
|
||||||
|
// The full order-list backs the auto-filled min field — keep it fresh too.
|
||||||
|
await qc.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = useMutation({
|
const submit = useMutation({
|
||||||
@@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
|||||||
toast.success("Change submitted for approval — the team has been notified");
|
toast.success("Change submitted for approval — the team has been notified");
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
|
onError: (err) => showError(err, "Failed to submit change"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const approve = useMutation({
|
const approve = useMutation({
|
||||||
@@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
|||||||
toast.success("Change approved and applied");
|
toast.success("Change approved and applied");
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
|
onError: (err) => showError(err, "Failed to approve change"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reject = useMutation({
|
const reject = useMutation({
|
||||||
@@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
|
|||||||
toast.success("Change rejected");
|
toast.success("Change rejected");
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
|
onError: (err) => showError(err, "Failed to reject change"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { pending, submit, approve, reject };
|
return { pending, submit, approve, reject };
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
|
|||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||||
|
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||||
@@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Priority rules never mutate directly: changes are filed for approval and a
|
// Priority rules never mutate directly: changes are filed for approval and a
|
||||||
// pending queue renders above the table.
|
// pending queue renders above the table. Validation errors (range collision,
|
||||||
|
// gap, ceiling) surface in a modal so the text is impossible to miss.
|
||||||
const isPriorityRules = config?.slug === "priority-configs";
|
const isPriorityRules = config?.slug === "priority-configs";
|
||||||
|
const [priorityError, setPriorityError] = useState<string | null>(null);
|
||||||
const priorityWorkflow = usePriorityRuleWorkflow(
|
const priorityWorkflow = usePriorityRuleWorkflow(
|
||||||
Boolean(isPriorityRules && canView),
|
Boolean(isPriorityRules && canView),
|
||||||
|
setPriorityError,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editingId = editing?.id ? String(editing.id) : undefined;
|
const editingId = editing?.id ? String(editing.id) : undefined;
|
||||||
@@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => {
|
|||||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
useWagonTypeOptions(usesWagonTypeField);
|
useWagonTypeOptions(usesWagonTypeField);
|
||||||
|
|
||||||
|
// Full rule list backing the auto-filled "min wagon count": the next range
|
||||||
|
// always continues the chain for the selected type (per currency), so the
|
||||||
|
// form needs every existing rule, not the current page.
|
||||||
|
const { data: allPriorityRules } = useRuleEngineOrderList(
|
||||||
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
|
Boolean(isPriorityRules && formOpen),
|
||||||
|
config?.orderConfig?.field,
|
||||||
|
);
|
||||||
|
|
||||||
const formFields = useMemo(() => {
|
const formFields = useMemo(() => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
return config.formFields.map((field) => {
|
return config.formFields.map((field) => {
|
||||||
|
if (isPriorityRules && field.name === "minWagonCount") {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
// Editing keeps the rule's own start (a lower gap never forces it to
|
||||||
|
// move); creating always continues the chain / fills the lowest gap.
|
||||||
|
computeValue: (values: Record<string, unknown>) =>
|
||||||
|
editing?.minWagonCount != null
|
||||||
|
? Number(editing.minWagonCount)
|
||||||
|
: nextPriorityRangeStart(
|
||||||
|
allPriorityRules ?? [],
|
||||||
|
String(values.type ?? ""),
|
||||||
|
!values.currency || values.currency === RULE_ENGINE_SELECT_NONE
|
||||||
|
? null
|
||||||
|
: String(values.currency),
|
||||||
|
editingId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
}
|
}
|
||||||
return field;
|
return field;
|
||||||
});
|
});
|
||||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
@@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={priorityError != null}
|
||||||
|
onClose={() => setPriorityError(null)}
|
||||||
|
title="Cannot save priority rule"
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="red.8">
|
||||||
|
{priorityError}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setPriorityError(null)}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<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%">
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ export interface FormFieldDef {
|
|||||||
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
|
||||||
*/
|
*/
|
||||||
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
getInitialValue?: (record: Record<string, unknown>) => unknown;
|
||||||
|
/**
|
||||||
|
* Fully derived field: its value is computed from the live form values on
|
||||||
|
* every render and the input is locked. Used for the priority-rule min
|
||||||
|
* wagon count, which always continues the previous range for the selected
|
||||||
|
* type. Return null/undefined to leave the field empty (e.g. chain full).
|
||||||
|
*/
|
||||||
|
computeValue?: (values: Record<string, unknown>) => number | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleEngineOrderConfig {
|
export interface RuleEngineOrderConfig {
|
||||||
@@ -134,6 +141,7 @@ const RATE_TRIGGERS = [
|
|||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
{ label: "Demurrage", value: "DEMURRAGE" },
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
|
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
@@ -155,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
|||||||
return ["PER_CONTAINER", "PER_TON"];
|
return ["PER_CONTAINER", "PER_TON"];
|
||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
return ["FLAT", "PER_INVOICE"];
|
||||||
|
case "CUSTOMS_CLEARANCE":
|
||||||
|
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
|
||||||
|
return ["FLAT"];
|
||||||
case "CONSOLIDATION":
|
case "CONSOLIDATION":
|
||||||
case "SHIPPING_LINE":
|
case "SHIPPING_LINE":
|
||||||
case "PIL_EXTRA_FEE":
|
case "PIL_EXTRA_FEE":
|
||||||
@@ -356,8 +367,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Select a currency",
|
placeholder: "Select a currency",
|
||||||
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
||||||
},
|
},
|
||||||
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
|
{
|
||||||
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
|
name: "minWagonCount",
|
||||||
|
label: "Min wagon count",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
disabled: true,
|
||||||
|
description: "Auto-filled — continues the previous range for the selected type",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxWagonCount",
|
||||||
|
label: "Max wagon count",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
|
||||||
|
},
|
||||||
{ name: "scorePoints", label: "Score points", type: "number", required: true },
|
{ name: "scorePoints", label: "Score points", type: "number", required: true },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Client mirror of the backend's contiguous-range rules for priority configs
|
||||||
|
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
|
||||||
|
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
|
||||||
|
* range always starts at the lowest uncovered wagon count. The backend
|
||||||
|
* re-validates on submit AND on approval; this only drives the form prefill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
|
||||||
|
|
||||||
|
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
|
||||||
|
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
|
||||||
|
WAGON: 50,
|
||||||
|
CURRENCY: 35,
|
||||||
|
CUSTOMS: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PriorityRangeRule {
|
||||||
|
id?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
currency?: unknown;
|
||||||
|
minWagonCount?: unknown;
|
||||||
|
maxWagonCount?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the next range for `type` (+`currency`) must start, excluding
|
||||||
|
* `excludeId` (the rule being edited). Null when the chain already covers
|
||||||
|
* 1..cap — no further rule fits.
|
||||||
|
*/
|
||||||
|
export function nextPriorityRangeStart(
|
||||||
|
rules: PriorityRangeRule[],
|
||||||
|
type: string,
|
||||||
|
currency: string | null | undefined,
|
||||||
|
excludeId?: string,
|
||||||
|
): number | null {
|
||||||
|
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
|
||||||
|
if (!cap) return null;
|
||||||
|
|
||||||
|
const scoped = rules
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
String(r.type ?? "") === type &&
|
||||||
|
(excludeId === undefined || String(r.id ?? "") !== excludeId) &&
|
||||||
|
(type !== "CURRENCY" ||
|
||||||
|
String(r.currency ?? "") === String(currency ?? "")),
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
min: Number(r.minWagonCount ?? 0),
|
||||||
|
max: Number(r.maxWagonCount ?? 0),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.min - b.min);
|
||||||
|
|
||||||
|
let next = 1;
|
||||||
|
for (const r of scoped) {
|
||||||
|
if (r.min > next) break; // gap before this rule — fill it first
|
||||||
|
next = Math.max(next, r.max + 1);
|
||||||
|
}
|
||||||
|
return next > cap ? null : next;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks";
|
|||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Hammer,
|
Hammer,
|
||||||
|
Pencil,
|
||||||
Ruler,
|
Ruler,
|
||||||
Search,
|
Search,
|
||||||
Train as TrainIcon,
|
Train as TrainIcon,
|
||||||
@@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||||
|
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
||||||
import {
|
import {
|
||||||
directionColor,
|
directionColor,
|
||||||
directionRowStyle,
|
directionRowStyle,
|
||||||
@@ -52,6 +55,7 @@ export default function TrainBuilderListPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
|
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
|
||||||
const [yardFilter, setYardFilter] = useState("ALL");
|
const [yardFilter, setYardFilter] = useState("ALL");
|
||||||
const [buildOpen, setBuildOpen] = useState(false);
|
const [buildOpen, setBuildOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
|
||||||
|
|
||||||
const resetPage = useCallback(() => {
|
const resetPage = useCallback(() => {
|
||||||
setPagination((prev) =>
|
setPagination((prev) =>
|
||||||
@@ -239,6 +243,26 @@ export default function TrainBuilderListPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Edit train ${row.original.code}`}
|
||||||
|
title="Edit name & train numbers"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Row click navigates to the detail page — keep the edit local.
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditTarget(row.original);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -363,6 +387,8 @@ export default function TrainBuilderListPage() {
|
|||||||
onClose={() => setBuildOpen(false)}
|
onClose={() => setBuildOpen(false)}
|
||||||
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
|
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditTrainDetailsModal train={editTarget} onClose={() => setEditTarget(null)} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ import {
|
|||||||
type BuiltTrainListResponse,
|
type BuiltTrainListResponse,
|
||||||
type ScheduleConsist,
|
type ScheduleConsist,
|
||||||
type TrainComposition,
|
type TrainComposition,
|
||||||
|
type UpdateTrainDetailsPayload,
|
||||||
} from "./trainBuilder.service";
|
} from "./trainBuilder.service";
|
||||||
import { trainSchedulingService } from "./trainScheduling.service";
|
import { trainSchedulingService } from "./trainScheduling.service";
|
||||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||||
@@ -1842,6 +1843,18 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
updateDetails: endpoint<
|
||||||
|
{ id: string; payload: UpdateTrainDetailsPayload },
|
||||||
|
TrainComposition
|
||||||
|
>(
|
||||||
|
"train-builder",
|
||||||
|
"updateDetails",
|
||||||
|
({ id, payload }) =>
|
||||||
|
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"assignWagons",
|
"assignWagons",
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ export interface BuildTrainPayload {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit a built train's display identity; omitted fields keep their value. */
|
||||||
|
export interface UpdateTrainDetailsPayload {
|
||||||
|
/** Empty string clears the name. */
|
||||||
|
trainName?: string;
|
||||||
|
importTrainNumber?: string;
|
||||||
|
exportTrainNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Built train annotated for the schedule-creation picker. */
|
/** Built train annotated for the schedule-creation picker. */
|
||||||
export interface AvailableTrain {
|
export interface AvailableTrain {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -241,6 +249,9 @@ export const trainBuilderService = {
|
|||||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||||
|
/** Edit the train's name and fixed import/export run numbers. */
|
||||||
|
updateDetails: (id: string, payload: UpdateTrainDetailsPayload) =>
|
||||||
|
apiClient.patch<TrainComposition>(`${BASE}/${id}/details`, payload),
|
||||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||||
setYard: (id: string, currentYardId: string) =>
|
setYard: (id: string, currentYardId: string) =>
|
||||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [
|
|||||||
"CONTRACT_ACTIVE",
|
"CONTRACT_ACTIVE",
|
||||||
"CONTRACT_CLOSED",
|
"CONTRACT_CLOSED",
|
||||||
// Post counter-sign document-clearance gate.
|
// Post counter-sign document-clearance gate.
|
||||||
|
"AWAITING_CLEARANCE_PAYMENT",
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||||
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 { ContractClearanceAction } from "./ContractClearanceAction";
|
||||||
@@ -69,6 +70,17 @@ export function ContractCustomerAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === "pay-clearance") {
|
||||||
|
return (
|
||||||
|
<PayClearanceFeeButton
|
||||||
|
sourceId={action.contractId}
|
||||||
|
currency={contract.paymentCurrency}
|
||||||
|
label={action.label}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === "initiate") {
|
if (action.type === "initiate") {
|
||||||
return (
|
return (
|
||||||
<InitiateBookingButton
|
<InitiateBookingButton
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export type ContractCustomerAction =
|
|||||||
label: string;
|
label: string;
|
||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
|
||||||
|
type: "pay-clearance";
|
||||||
|
contractId: string;
|
||||||
|
label: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
function findPayableBookingForContract(
|
function findPayableBookingForContract(
|
||||||
@@ -137,6 +145,17 @@ export function deriveContractCustomerAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepaid clearance service fee gate — must settle before document upload.
|
||||||
|
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||||
|
return {
|
||||||
|
type: "pay-clearance",
|
||||||
|
contractId: id,
|
||||||
|
label: "Pay clearance fee",
|
||||||
|
primary: true,
|
||||||
|
icon: CreditCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const payable = findPayableBookingForContract(id, bookings);
|
const payable = findPayableBookingForContract(id, bookings);
|
||||||
if (payable) {
|
if (payable) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
|
|||||||
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: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee";
|
||||||
/** 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. */
|
||||||
@@ -40,6 +40,18 @@ export function deriveActionItems(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Prepaid clearance service fee (Path B) — blocks the document step.
|
||||||
|
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||||
|
items.push({
|
||||||
|
id: `clearance-fee-${c.id}`,
|
||||||
|
kind: "clearance-fee",
|
||||||
|
reference: c.reference,
|
||||||
|
description: "Clearance service fee due — pay to unlock document upload",
|
||||||
|
targetId: c.id,
|
||||||
|
urgent: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const clr = contractNeedsClearanceAction(c);
|
const clr = contractNeedsClearanceAction(c);
|
||||||
if (clr.show) {
|
if (clr.show) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -70,6 +82,19 @@ export function deriveActionItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
|
// Per-shipment clearance service fee (GENERAL + customs shipment request).
|
||||||
|
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||||
|
items.push({
|
||||||
|
id: `clearance-fee-${b.id}`,
|
||||||
|
kind: "clearance-fee",
|
||||||
|
reference: b.reference,
|
||||||
|
description:
|
||||||
|
"Clearance service fee due for this shipment — pay to unlock document upload",
|
||||||
|
targetId: b.id,
|
||||||
|
urgent: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||||
const canPay =
|
const canPay =
|
||||||
b.paymentStatus !== "PAID" &&
|
b.paymentStatus !== "PAID" &&
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const KIND_META: Record<
|
|||||||
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" },
|
||||||
|
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ActionNeededSectionProps {
|
export interface ActionNeededSectionProps {
|
||||||
@@ -137,9 +138,14 @@ export function ActionNeededSection({
|
|||||||
|
|
||||||
// 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).
|
||||||
|
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
|
||||||
|
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
|
||||||
|
// booking id depending on where the gate sits).
|
||||||
|
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
|
||||||
const { data: payItemInvoices = [] } = useQuery({
|
const { data: payItemInvoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", payItem?.targetId],
|
queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
|
||||||
queryFn: () => invoicesService.listForSource("booking", payItem!.targetId),
|
queryFn: () =>
|
||||||
|
invoicesService.listForSource(payItemSource, payItem!.targetId),
|
||||||
enabled: payItem !== null,
|
enabled: payItem !== null,
|
||||||
});
|
});
|
||||||
const payableInvoiceId = payItemInvoices.find((inv) =>
|
const payableInvoiceId = payItemInvoices.find((inv) =>
|
||||||
@@ -182,6 +188,7 @@ export function ActionNeededSection({
|
|||||||
navigate(`/contracts/${item.targetId}`);
|
navigate(`/contracts/${item.targetId}`);
|
||||||
break;
|
break;
|
||||||
case "pay":
|
case "pay":
|
||||||
|
case "clearance-fee":
|
||||||
setPayItem(item);
|
setPayItem(item);
|
||||||
break;
|
break;
|
||||||
case "sign":
|
case "sign":
|
||||||
@@ -277,7 +284,9 @@ export function ActionNeededSection({
|
|||||||
>
|
>
|
||||||
{item.kind === "pay"
|
{item.kind === "pay"
|
||||||
? "Pay now"
|
? "Pay now"
|
||||||
: item.kind === "duty"
|
: item.kind === "clearance-fee"
|
||||||
|
? "Pay clearance fee"
|
||||||
|
: item.kind === "duty"
|
||||||
? "Pay duty & upload slip"
|
? "Pay duty & upload slip"
|
||||||
: item.kind === "sign"
|
: item.kind === "sign"
|
||||||
? "Sign"
|
? "Sign"
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
badgeDot: "edr-green.5",
|
badgeDot: "edr-green.5",
|
||||||
action: { label: "View", kind: "outline" },
|
action: { label: "View", kind: "outline" },
|
||||||
},
|
},
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: {
|
||||||
|
stage: 3,
|
||||||
|
icon: Wallet,
|
||||||
|
iconColor: "edr-amber-text",
|
||||||
|
tile: "edr-amber-soft",
|
||||||
|
hint: "Clearance service fee due · pay to unlock document upload",
|
||||||
|
step: "edr-accent",
|
||||||
|
badgeLabel: "Clearance fee due",
|
||||||
|
badgeBg: "edr-amber-soft",
|
||||||
|
badgeText: "edr-amber-text",
|
||||||
|
badgeDot: "edr-accent",
|
||||||
|
action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
|
||||||
|
},
|
||||||
AWAITING_DOCUMENTS: {
|
AWAITING_DOCUMENTS: {
|
||||||
stage: 3,
|
stage: 3,
|
||||||
icon: FileUp,
|
icon: FileUp,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Tabs } from "@mantine/core";
|
import { Group, Paper, Tabs, Text } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
|
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { DocumentsTab } from "./components/DocumentsTab";
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
@@ -139,6 +140,8 @@ export function ReadonlyBookingView({
|
|||||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||||
const canSelfRebook = !isCustoms;
|
const canSelfRebook = !isCustoms;
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
|
// Prepaid clearance service fee gate — document upload stays locked until paid.
|
||||||
|
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
|
||||||
const isClearance = [
|
const isClearance = [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
@@ -237,6 +240,28 @@ export function ReadonlyBookingView({
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<ContractCard booking={booking} />
|
<ContractCard booking={booking} />
|
||||||
|
|
||||||
|
{isAwaitingClearanceFee && (
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz={15} c="#92400E">
|
||||||
|
Customs clearance service fee due
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="#B45309" mt={4}>
|
||||||
|
Pay the clearance service fee to unlock the clearance
|
||||||
|
document upload. Global Logistics starts working on your
|
||||||
|
shipment once the fee is settled.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<PayClearanceFeeButton
|
||||||
|
sourceId={booking.id}
|
||||||
|
currency={booking.paymentCurrency}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
{isClearance && <ClearanceCard booking={booking} />}
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
CreditCard,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -11,6 +12,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||||
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
|
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
|
||||||
|
|
||||||
import { BookingActionModal } from "./BookingActionModal";
|
import { BookingActionModal } from "./BookingActionModal";
|
||||||
@@ -23,6 +25,7 @@ const ICON_BY_KIND: Record<
|
|||||||
BookingActionKind,
|
BookingActionKind,
|
||||||
typeof Upload
|
typeof Upload
|
||||||
> = {
|
> = {
|
||||||
|
PAY_CLEARANCE: CreditCard,
|
||||||
UPLOAD_DOCUMENTS: Upload,
|
UPLOAD_DOCUMENTS: Upload,
|
||||||
FIX_DOCUMENTS: AlertCircle,
|
FIX_DOCUMENTS: AlertCircle,
|
||||||
SCHEDULE_OPERATION: ArrowRight,
|
SCHEDULE_OPERATION: ArrowRight,
|
||||||
@@ -56,6 +59,19 @@ export function BookingActionButton({
|
|||||||
|
|
||||||
if (!isChangesRequested && !action) return null;
|
if (!isChangesRequested && !action) return null;
|
||||||
|
|
||||||
|
// The prepaid clearance service fee has its own payment flow (method modal +
|
||||||
|
// provider redirect) — delegate to the self-contained pay button.
|
||||||
|
if (action?.kind === "PAY_CLEARANCE") {
|
||||||
|
return (
|
||||||
|
<PayClearanceFeeButton
|
||||||
|
sourceId={booking.id}
|
||||||
|
currency={booking.paymentCurrency}
|
||||||
|
label={action.label}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
|
||||||
const label = action ? action.label : "Update & resubmit";
|
const label = action ? action.label : "Update & resubmit";
|
||||||
// BOOK navigates to the booking form (cargo + day + window check) — the
|
// BOOK navigates to the booking form (cargo + day + window check) — the
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
|
|||||||
* to operation.
|
* to operation.
|
||||||
*/
|
*/
|
||||||
export type BookingActionKind =
|
export type BookingActionKind =
|
||||||
|
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
|
||||||
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
|
||||||
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
|
||||||
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
|
||||||
@@ -23,6 +24,11 @@ export interface BookingNextAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: {
|
||||||
|
kind: "PAY_CLEARANCE",
|
||||||
|
label: "Pay clearance fee",
|
||||||
|
title: "Pay the clearance service fee",
|
||||||
|
},
|
||||||
AWAITING_DOCUMENTS: {
|
AWAITING_DOCUMENTS: {
|
||||||
kind: "UPLOAD_DOCUMENTS",
|
kind: "UPLOAD_DOCUMENTS",
|
||||||
label: "Upload documents",
|
label: "Upload documents",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Button, type ButtonProps } from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { CreditCard } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||||
|
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
|
import {
|
||||||
|
paymentsService,
|
||||||
|
type PaymentMethod,
|
||||||
|
} from "@/services/payments.service";
|
||||||
|
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment flow for the prepaid customs clearance service fee. The fee is its
|
||||||
|
* own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
|
||||||
|
* contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
|
||||||
|
* shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
|
||||||
|
* unlocks the clearance document upload; same modal + provider redirect as
|
||||||
|
* booking payment.
|
||||||
|
*/
|
||||||
|
export function useClearanceFeePayment(sourceId: string) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: invoices = [] } = useQuery({
|
||||||
|
queryKey: ["clearance-invoices", sourceId],
|
||||||
|
queryFn: () => invoicesService.listForSource("clearance", sourceId),
|
||||||
|
enabled: Boolean(sourceId),
|
||||||
|
});
|
||||||
|
const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (method: PaymentMethod) => {
|
||||||
|
if (!payableInvoice) {
|
||||||
|
throw new Error(
|
||||||
|
"No payable clearance-fee invoice found yet. Please refresh or contact support.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return api.invoices.pay.call({
|
||||||
|
id: payableInvoice.id,
|
||||||
|
payload: { method, platform: "web" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data, method) => {
|
||||||
|
const redirectUrl =
|
||||||
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
|
? data.clientAction.url
|
||||||
|
: paymentsService.checkoutUrlForInvoice({
|
||||||
|
invoiceId: payableInvoice!.id,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (!mutation.isPending) {
|
||||||
|
setModalOpen(false);
|
||||||
|
mutation.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice: payableInvoice,
|
||||||
|
modalOpen,
|
||||||
|
open: () => setModalOpen(true),
|
||||||
|
close,
|
||||||
|
processing: mutation.isPending,
|
||||||
|
error: mutation.isError
|
||||||
|
? mutation.error instanceof Error
|
||||||
|
? mutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null,
|
||||||
|
confirm: (method: PaymentMethod) => mutation.mutate(method),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayClearanceFeeButtonProps {
|
||||||
|
/** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
|
||||||
|
sourceId: string;
|
||||||
|
/** Fallback currency while the invoice is loading. */
|
||||||
|
currency?: string;
|
||||||
|
label?: string;
|
||||||
|
size?: ButtonProps["size"];
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
|
||||||
|
export function PayClearanceFeeButton({
|
||||||
|
sourceId,
|
||||||
|
currency,
|
||||||
|
label = "Pay clearance fee",
|
||||||
|
size = "xs",
|
||||||
|
fullWidth,
|
||||||
|
}: PayClearanceFeeButtonProps) {
|
||||||
|
const pay = useClearanceFeePayment(sourceId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalSafeWrapper>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
leftSection={<CreditCard size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
pay.open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={pay.modalOpen}
|
||||||
|
onClose={pay.close}
|
||||||
|
amountLabel={
|
||||||
|
pay.invoice
|
||||||
|
? `${Number(pay.invoice.totalAmount).toLocaleString()} ${pay.invoice.currency}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
currency={pay.invoice?.currency ?? currency}
|
||||||
|
processing={pay.processing}
|
||||||
|
error={pay.error}
|
||||||
|
onConfirm={pay.confirm}
|
||||||
|
/>
|
||||||
|
</ModalSafeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
|
|||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
|
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
|
||||||
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";
|
||||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||||
@@ -396,6 +397,9 @@ export default function ContractDetailPage() {
|
|||||||
// clearance is finalized.
|
// clearance is finalized.
|
||||||
const canUploadClearance =
|
const canUploadClearance =
|
||||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
||||||
|
// Prepaid clearance service fee gate (Path B) — the document step stays
|
||||||
|
// locked until the fee invoice settles.
|
||||||
|
const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
@@ -531,6 +535,13 @@ export default function ContractDetailPage() {
|
|||||||
Global Logistics is creating your booking
|
Global Logistics is creating your booking
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{awaitingClearanceFee && (
|
||||||
|
<PayClearanceFeeButton
|
||||||
|
sourceId={contract.id}
|
||||||
|
currency={contract.paymentCurrency}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canUploadClearance && (
|
{canUploadClearance && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -875,6 +886,12 @@ export default function ContractDetailPage() {
|
|||||||
{item.containerSize}
|
{item.containerSize}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{item.isClearance && (
|
||||||
|
<Text fz={12} c="orange.7" fw={600}>
|
||||||
|
Paid in advance, before clearance — excluded from
|
||||||
|
shipment invoices
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
||||||
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
|
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
|
||||||
|
|||||||
@@ -1005,6 +1005,12 @@ export default function NewContractPage({
|
|||||||
{item.containerSize}
|
{item.containerSize}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{item.isClearance && (
|
||||||
|
<Text size="xs" c="orange.7" fw={600}>
|
||||||
|
Paid in advance, before clearance — not part of your
|
||||||
|
shipment booking invoice
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ export default function NewShipmentRequestPage() {
|
|||||||
contractsService.submitBookingRequest(id!, dto),
|
contractsService.submitBookingRequest(id!, dto),
|
||||||
onSuccess: (request) => {
|
onSuccess: (request) => {
|
||||||
// Clearance-first flow: the request auto-initiates a booking instance —
|
// Clearance-first flow: the request auto-initiates a booking instance —
|
||||||
// send the customer straight to it to upload clearance documents.
|
// send the customer straight to it. The clearance service fee is due
|
||||||
|
// first; document upload unlocks once it settles.
|
||||||
if (request.createdBookingId) {
|
if (request.createdBookingId) {
|
||||||
toast.success(
|
toast.success(
|
||||||
"Shipment initiated — upload your clearance documents to start the review.",
|
"Shipment initiated — pay the clearance service fee to unlock the document upload.",
|
||||||
);
|
);
|
||||||
navigate(`/bookings/${request.createdBookingId}`);
|
navigate(`/bookings/${request.createdBookingId}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
|||||||
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
|
||||||
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
|
||||||
// ── Path B pre-booking clearance (contract-level) ──
|
// ── Path B pre-booking clearance (contract-level) ──
|
||||||
|
AWAITING_CLEARANCE_PAYMENT: {
|
||||||
|
label: "Clearance Fee Due",
|
||||||
|
...TONE.warning,
|
||||||
|
},
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Upload Clearance Docs",
|
label: "Upload Clearance Docs",
|
||||||
...TONE.warning,
|
...TONE.warning,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const CONTRACT_STATUSES = [
|
|||||||
"FULLY_EXECUTED", // ONE_TIME
|
"FULLY_EXECUTED", // ONE_TIME
|
||||||
"CONTRACT_ACTIVE", // GENERAL
|
"CONTRACT_ACTIVE", // GENERAL
|
||||||
// customs clearance execution (Path B, pre-booking)
|
// customs clearance execution (Path B, pre-booking)
|
||||||
|
"AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
@@ -67,6 +68,7 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
|||||||
*/
|
*/
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
"NOT_APPLICABLE",
|
"NOT_APPLICABLE",
|
||||||
|
"AWAITING_PAYMENT", // Path B — clearance service fee must be paid first
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
|
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
|
||||||
@@ -102,6 +104,11 @@ export interface ContractUnitRateLineItem {
|
|||||||
/** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */
|
/** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
cargoTypeCode?: string | null;
|
cargoTypeCode?: string | null;
|
||||||
|
/**
|
||||||
|
* True for the customs clearance service fee — billed separately in advance
|
||||||
|
* (before document upload), never part of shipment booking totals.
|
||||||
|
*/
|
||||||
|
isClearance?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contract `pricing_breakdown` shape — unit rates, no totals. */
|
/** Contract `pricing_breakdown` shape — unit rates, no totals. */
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export enum BookingStatus {
|
|||||||
PendingConsolidation = "PENDING_CONSOLIDATION",
|
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||||
Consolidated = "CONSOLIDATED",
|
Consolidated = "CONSOLIDATED",
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
|
/** Clearance service fee invoiced; docs + GL work locked until paid. */
|
||||||
|
AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT",
|
||||||
AwaitingDocuments = "AWAITING_DOCUMENTS",
|
AwaitingDocuments = "AWAITING_DOCUMENTS",
|
||||||
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
|
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
|
||||||
ClearanceReady = "CLEARANCE_READY",
|
ClearanceReady = "CLEARANCE_READY",
|
||||||
@@ -173,7 +175,9 @@ export enum InvoiceSource {
|
|||||||
Warehouse = "warehouse",
|
Warehouse = "warehouse",
|
||||||
Demurrage = "demurrage",
|
Demurrage = "demurrage",
|
||||||
FirstMile = "firstmile",
|
FirstMile = "firstmile",
|
||||||
LastMile = "lastmile"
|
LastMile = "lastmile",
|
||||||
|
/** Customs clearance service fee, prepaid before clearance work begins. */
|
||||||
|
Clearance = "clearance"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SchedulingStatus {
|
export enum SchedulingStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user