add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 16:58:51 +00:00
parent 9b13fa2ac6
commit 5e10c97294
74 changed files with 3684 additions and 342 deletions

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Store WHO made a contract edit as a name, not just an id. Denormalised on
* purpose: an audit trail must still read correctly after the user is renamed,
* deactivated or deleted, and `iam.users` lives outside this module's schema.
*/
export class AddRevisionActorName2920000000000 implements MigrationInterface {
name = 'AddRevisionActorName2920000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order
* was collected, not just attach the DO file. Both are mandatory on DO upload
* (enforced in the clearance services), so the columns are new and nullable —
* DOs uploaded before this change have no dates to backfill.
*
* `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the
* import arrival date gets its own column rather than overloading it.
*/
export class AddDoCollectionDates2930000000000 implements MigrationInterface {
name = 'AddDoCollectionDates2930000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const table of [
'freight.contract_clearance_cycles',
'freight.bookings',
]) {
await queryRunner.query(
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`,
);
await queryRunner.query(
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const table of [
'freight.contract_clearance_cycles',
'freight.bookings',
]) {
await queryRunner.query(
`ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`,
);
await queryRunner.query(
`ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`,
);
}
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Currency moved from the contract to the shipment: a contract now quotes in
* USD and the customer picks the billing currency per booking. On a customs
* contract GL books on the customer's behalf, so the shipment request is where
* the customer states the currency — GL reads it when creating the booking.
*
* Nullable: requests submitted before this change fall back to the contract's
* own currency, which is exactly what their bookings already used.
*/
export class AddBookingRequestCurrency2940000000000 implements MigrationInterface {
name = 'AddBookingRequestCurrency2940000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Keep every version of a stored document.
*
* Replacing a file used to DELETE the previous row outright, so a staff
* correction erased the customer's original upload with no trail. Superseded
* versions are now soft-deleted (already excluded from every read by TypeORM's
* soft-delete filter) and stamped with who replaced them and why, which is what
* the document's version history reads back.
*/
export class AddFileVersionHistory2940000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL,
ADD COLUMN IF NOT EXISTS replace_reason text NULL
`);
// History reads walk one document's versions, deleted rows included.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_files_version_history"
ON freight.files (resource, resource_id, code, created_at DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`);
await queryRunner.query(`
ALTER TABLE freight.files
DROP COLUMN IF EXISTS replaced_by_user_id,
DROP COLUMN IF EXISTS replace_reason
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake before the customs declaration.
*
* GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and
* Djibouti answers with a name, before the declaration can be filed. The whole
* exchange lives on the clearance cycle so it repeats naturally with each cycle
* of a GENERAL contract.
*/
export class AddTransitAssigneeHandshake2950000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_clearance_cycles
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_clearance_cycles
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at,
DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id
`);
}
}

View File

@@ -200,7 +200,7 @@ export class BookingPricingService {
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -547,14 +547,15 @@ export class BookingPricingService {
booking.originYardId,
booking.destinationYardId,
);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
// It also stands on its own: a contract line prices off the agreed rate
// even when nobody configured a live rate for this leg + type yet.
// H15: frozen contract rate for this container size, when present —
// converted into the booking currency by frozenRateForContainer. It also
// stands on its own: a contract line prices off the agreed rate even when
// nobody configured a live rate for this leg + type yet.
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
usdToEtb,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
@@ -632,7 +633,7 @@ export class BookingPricingService {
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
: null;
let amount: number;
let unitAmount: number;
@@ -744,12 +745,13 @@ export class BookingPricingService {
break;
}
// H15: frozen mile rate (already in booking currency) when the contract
// has one; else the live USD rate converted as before.
// H15: frozen mile rate (converted into the booking currency) when the
// contract has one; else the live USD rate converted as before.
const frozen = this.frozenRateByCode(
frozenRates,
leg.rateType,
paymentCurrency,
usdToEtb,
);
let amount: number;
let unitAmount: number;
@@ -908,20 +910,45 @@ export class BookingPricingService {
}
/**
* The frozen snapshot for a rate code, or null when there is none, its price
* is negative, or it is in a different currency than the booking (in which
* case the live-rate path is safer than a mis-converted frozen price).
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
*
* A contract quotes in USD and freezes USD unit prices; the customer chooses
* the billing currency per booking. So a currency mismatch is the normal case
* now, not an error — the snapshot is converted rather than discarded. (It
* previously returned null on mismatch, which silently dropped the agreed
* contract price and re-priced the booking at whatever the live rate had
* drifted to.) Grandfathered ETB contracts convert the other way for the same
* reason.
*
* Returns null only when there is no snapshot or its price is unusable.
*/
private frozenRateByCode(
frozenRates: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
usdToEtb: number,
): ContractRateSnapshot | null {
const snap = frozenRates?.get(code);
if (!snap) return null;
if (snap.currency !== bookingCurrency) return null;
if (!(Number(snap.unitPrice) >= 0)) return null;
return snap;
const unitPrice = Number(snap.unitPrice);
if (!(unitPrice >= 0)) return null;
if (snap.currency === bookingCurrency) return snap;
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
if (!(usdToEtb > 0)) return null;
const converted =
snap.currency === 'USD' && bookingCurrency === 'ETB'
? Math.round(unitPrice * usdToEtb)
: snap.currency === 'ETB' && bookingCurrency === 'USD'
? unitPrice / usdToEtb
: null;
if (converted == null) return null;
// A copy — the snapshot rows are shared across the pricing pass.
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
unitPrice: converted,
currency: bookingCurrency,
}) as ContractRateSnapshot;
}
/**
@@ -933,6 +960,7 @@ export class BookingPricingService {
frozenRates: Map<string, ContractRateSnapshot> | null,
containerTypeId: string,
bookingCurrency: string,
usdToEtb: number,
): Promise<ContractRateSnapshot | null> {
if (!frozenRates) return null;
let sizeFt: number | null = null;
@@ -942,7 +970,7 @@ export class BookingPricingService {
return null;
}
if (!sizeFt) return null;
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
}
/**
@@ -985,7 +1013,7 @@ export class BookingPricingService {
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
@@ -1014,7 +1042,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1049,7 +1077,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
const live =
(booking.cargoTypeId
? onLeg.find(

View File

@@ -916,14 +916,15 @@ export class BookingsController {
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
{ vesselArrivalDate, doCollectedDate },
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
'booking.files',
FileRecord,
'file',
"file.resource_id = booking.id AND file.resource = 'bookings'",
// Superseded versions are soft-deleted, not dropped — keep them out of
// the live file list (a manual join condition is not filtered for us).
"file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
)
.getOne();

View File

@@ -526,6 +526,14 @@ export class Booking extends BaseEntity {
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
vesselDepartureDate?: string | null;
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
vesselArrivalDate?: string | null;
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
doCollectedDate?: string | null;
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
roAmendmentRequestedAt?: Date | null;

View File

@@ -19,6 +19,7 @@ import {
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
@@ -64,6 +65,9 @@ export interface BookingClearanceView {
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
/** Import DO dates recorded by GL Djibouti on upload. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
@@ -261,6 +265,8 @@ export class BookingClearanceService {
roHold: Boolean(booking.roHoldReason),
roHoldReason: booking.roHoldReason ?? null,
vesselDepartureDate: booking.vesselDepartureDate ?? null,
vesselArrivalDate: booking.vesselArrivalDate ?? null,
doCollectedDate: booking.doCollectedDate ?? null,
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
? booking.roAmendmentRequestedAt.toISOString()
: null,
@@ -547,7 +553,7 @@ export class BookingClearanceService {
bookingId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
@@ -556,6 +562,8 @@ export class BookingClearanceService {
if (!file) throw new BadRequestException('No Delivery Order uploaded');
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and operation readiness) still
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
@@ -566,11 +574,10 @@ export class BookingClearanceService {
file,
});
if (vesselDepartureDate?.trim()) {
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate: vesselDepartureDate.trim(),
vesselArrivalDate,
doCollectedDate,
} as never);
}
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);

View File

@@ -30,18 +30,35 @@ export class BookingRequestService {
private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
private assertGeneralCustoms(contract: Contract): void {
if (
contract.contractKind !== 'GENERAL' ||
!contract.customsClearingEnabled
) {
/**
* Shipment requests exist because on a CUSTOMS contract the customer never
* books directly — GL Ethiopia does it for them. The request is how the
* customer states what to ship and, now, which currency to be invoiced in.
*
* GENERAL: each request opens its own per-booking clearance instance.
* ONE_TIME: clearance already ran at the contract level, so the request only
* records the customer's intent; GL creates the single booking from it.
*/
private assertCustomsContract(contract: Contract): void {
if (!contract.customsClearingEnabled) {
throw new BadRequestException(
'Shipment requests apply only to general customs-clearance contracts.',
'Shipment requests apply only to customs-clearance contracts.',
);
}
}
/**
* Statuses in which a ONE_TIME customs contract may take a shipment request:
* both signatures are in and the contract is at (or past) its clearance
* phase, but GL has not booked yet.
*/
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
'FULLY_EXECUTED',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
];
/** Customer submits a shipment request. */
async submit(
contractId: string,
@@ -50,13 +67,35 @@ export class BookingRequestService {
): Promise<BookingRequest> {
const contract = await this.contractsService.findById(contractId);
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
this.assertGeneralCustoms(contract);
this.assertCustomsContract(contract);
const isOneTime = contract.contractKind === 'ONE_TIME';
if (contract.status === 'CONTRACT_CLOSED') {
throw new ConflictException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
if (isOneTime) {
if (
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
contract.status,
)
) {
throw new ConflictException(
'The contract must be fully executed before requesting its shipment.',
);
}
// A one-time contract carries exactly one shipment, so it carries at most
// one open request — otherwise GL sees two conflicting currencies.
const open = (await this.repo.findForContract(contractId)).find(
(r) => r.status === 'PENDING',
);
if (open) {
throw new ConflictException(
`Shipment request ${open.reference} is already open on this contract.`,
);
}
} else if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',
);
@@ -90,10 +129,14 @@ export class BookingRequestService {
}
}
}
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
// shipment is bounded by the contract scope itself, checked when GL books.
if (!isOneTime) {
await this.contractBookingService.assertRequestWithinCapacity(contract, {
containers: dto.containers,
bulk: dto.bulk,
});
}
const requestedLines: Freight.RequestedShipmentLines = isContainer
? {
@@ -119,10 +162,17 @@ export class BookingRequestService {
// reviews the documents in the clearance queue and completes the booking
// (container numbers, VGM, shipment day) once clearance is ready. The
// instance is created first so a failure leaves no half-linked request.
const booking = await this.contractBookingService.initiateForShipmentRequest(
contract,
{ contractRouteId: dto.contractRouteId, userId },
);
// GENERAL: the request immediately opens a BARE booking instance that runs
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
// the contract, so there is nothing to open — the request stays PENDING
// until GL creates the contract's single booking from it.
const booking = isOneTime
? null
: await this.contractBookingService.initiateForShipmentRequest(contract, {
contractRouteId: dto.contractRouteId,
userId,
paymentCurrency: dto.paymentCurrency,
});
const reference = await this.generateReference();
const request = await this.repo.create({
@@ -131,9 +181,14 @@ export class BookingRequestService {
requestedByUserId: userId ?? null,
contractRouteId: dto.contractRouteId ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
status: 'ACCEPTED',
createdBookingId: booking.id,
status: booking ? 'ACCEPTED' : 'PENDING',
createdBookingId: booking?.id ?? null,
requestedLines,
// Intercity is invoiced in birr whatever the customer picked.
paymentCurrency:
contract.tradeDirection === 'DOMESTIC'
? 'ETB'
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
notes: dto.notes ?? null,
} as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);

View File

@@ -277,7 +277,7 @@ export class ContractBookingService {
createdByUserId: user?.id ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
@@ -481,7 +481,7 @@ export class ContractBookingService {
createdByUserId: user?.id ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
paymentCurrency: this.resolveShipmentCurrency(contract, null),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
@@ -520,7 +520,12 @@ export class ContractBookingService {
*/
async initiateForShipmentRequest(
contract: Contract,
opts: { contractRouteId?: string; userId?: string | null },
opts: {
contractRouteId?: string;
userId?: string | null;
/** Billing currency the customer chose on the shipment request. */
paymentCurrency?: string | null;
},
): Promise<Booking> {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
@@ -555,7 +560,7 @@ export class ContractBookingService {
createdByUserId: opts.userId ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
@@ -733,6 +738,13 @@ export class ContractBookingService {
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
// created before the customer had any figure to look at.
paymentCurrency: this.resolveShipmentCurrency(
contract,
dto.paymentCurrency ?? booking.paymentCurrency,
),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
@@ -1565,6 +1577,23 @@ export class ContractBookingService {
* booking-level override (dto.equipmentReturn ?? contract default) applies.
* Bulk freight keeps the legacy behaviour untouched.
*/
/**
* The billing currency for a shipment under this contract.
*
* A contract quotes in USD only — the currency is a per-shipment choice now.
* Precedence: intercity is always ETB (domestic transport is invoiced in
* birr), then the customer's explicit choice, then the contract's own
* currency, which is USD for contracts created under the current rule and the
* grandfathered value for older ones.
*/
private resolveShipmentCurrency(
contract: Contract,
requested?: string | null,
): string {
if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
return requested?.trim() || contract.paymentCurrency || 'USD';
}
private resolveShipmentEquipmentReturn(
contract: Contract,
dto: CreateBookingUnderContractDto,
@@ -1795,7 +1824,7 @@ export class ContractBookingService {
contractId: contract.id,
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
paymentCurrency: contract.paymentCurrency,
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),

View File

@@ -1,4 +1,9 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
@@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import {
assertDoCollectionDates,
contractClearanceCodes,
} from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
@@ -72,9 +80,23 @@ export interface ContractClearanceView {
blockedReason?: string | null;
} | null;
dutyRequired?: boolean | null;
/**
* Pre-declaration handshake with GL Djibouti: who will handle the shipment in
* transit. `name` is null until Djibouti answers, and the declaration step is
* shut until it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
/** Import DO dates recorded by GL Djibouti on upload. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
roAmendmentRequestedAt?: string | null;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
@@ -98,6 +120,16 @@ export interface ContractClearanceView {
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty — present only while GL
* has not re-advised (the advice milestone is back to PENDING). `rounds` is
* how many times it has been sent back, so both sides can see the loop.
*/
dutyDispute?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until a booking is linked). */
t1?: ClearanceT1State | null;
@@ -247,6 +279,19 @@ export class ContractClearanceService {
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const dutyDispute = await this.buildDutyDispute(contractId, milestones);
const transitAssignee = cycle
? {
requestedAt: cycle.transitAssigneeRequestedAt
? cycle.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: cycle.transitAssigneeRequestNote ?? null,
name: cycle.transitAssigneeName ?? null,
assignedAt: cycle.transitAssigneeAssignedAt
? cycle.transitAssigneeAssignedAt.toISOString()
: null,
}
: null;
let workflowFiles = buildWorkflowFiles(
files,
contract.tradeDirection ?? 'IMPORT',
@@ -361,6 +406,8 @@ export class ContractClearanceService {
roHold: Boolean(cycle?.roHoldReason),
roHoldReason: cycle?.roHoldReason ?? null,
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
doCollectedDate: cycle?.doCollectedDate ?? null,
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
? cycle.roAmendmentRequestedAt.toISOString()
: null,
@@ -373,6 +420,8 @@ export class ContractClearanceService {
linkedBookingReviewNote,
linkedBookingScheduledDate,
dutyAdvice,
dutyDispute,
transitAssignee,
workflowFiles,
t1,
train,
@@ -429,6 +478,32 @@ export class ContractClearanceService {
};
}
/**
* The customer's duty objection, but only while it is still OPEN — i.e. the
* advice milestone sits back at PENDING because nobody has re-advised yet.
* Re-advising completes that milestone again, which closes the dispute here
* without any extra state to keep in sync; the notes stay as the audit trail
* and their count is the round number.
*/
private async buildDutyDispute(
contractId: string,
milestones: ClearanceMilestone[],
): Promise<ContractClearanceView['dutyDispute']> {
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
if (!advised || advised.status === 'COMPLETED') return null;
const notes = await this.contractsRepository.findReviewNotes(
contractId,
'DUTY_DISPUTE',
);
const latest = notes[0];
if (!latest) return null;
return {
note: latest.body,
raisedAt: latest.createdAt.toISOString(),
rounds: notes.length,
};
}
/**
* True when every REQUIRED customer-input field has an APPROVED review row in
* the current cycle. The 100% gate before clearance can be finalized.
@@ -654,6 +729,92 @@ export class ContractClearanceService {
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
}
/**
* GL corrects a clearance document in place instead of bouncing it back to
* the customer. The customer's upload is NOT lost — it is retired into the
* document's version history, stamped with who replaced it and why — and the
* new version starts unreviewed, so GL still has to approve it (or query it)
* before clearance can be finalized.
*
* Use this for the small fixes staff can make faster than the customer can
* (a wrong page order, a missing stamp scan); a query is still the right tool
* when only the customer can produce the correct document.
*/
async replaceDocument(
contractId: string,
fileKey: string,
file: Express.Multer.File,
staffId: string,
reason?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertClearanceReviewableStatus(contract);
if (!file) throw new BadRequestException('No replacement file uploaded');
if (!reason?.trim()) {
throw new BadRequestException(
'Say why the document is being replaced — it is kept on the file history.',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
throw new BadRequestException(
'Documents cannot be changed after pre-clearance is finalized.',
);
}
const existing = await this.filesService.findByCode(
contractId,
'contracts',
fileKey,
);
if (!existing) {
throw new NotFoundException(
`No document is stored under "${fileKey}" on this contract.`,
);
}
await this.filesService.upsertByCode(
{ resourceId: contractId, resource: 'contracts', code: fileKey, file },
{ userId: staffId, reason: reason.trim() },
);
// A fresh version is unreviewed by definition: clear any earlier verdict so
// the corrected file is signed off explicitly rather than inheriting a tick.
const { inputCode, outputCode } = contractClearanceCodes(contract);
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const settingCode =
reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
await this.contractsRepository.setDocumentReviewStatus({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey,
status: 'PENDING',
staffId,
note: `Replaced by staff: ${reason.trim()}`,
});
await this.contractsRepository.createReviewNote(
contractId,
`Document "${fileKey}" replaced by staff: ${reason.trim()}`,
'STAFF_NOTE',
staffId,
'GL_ET',
);
return this.contractsService.findById(contractId);
}
/** Every stored version of one clearance document, newest first. */
async documentVersions(contractId: string, fileKey: string) {
return this.filesService.versionHistory(contractId, 'contracts', fileKey);
}
private async applyReview(
contractId: string,
fileKey: string,
@@ -968,6 +1129,68 @@ export class ContractClearanceService {
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
/**
* GL Ethiopia asks Djibouti to name the officer who will handle the shipment
* in transit. Nothing else moves until Djibouti answers — the declaration is
* gated on it — so this is the first thing ET does once the documents are
* approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
contractId: string,
note: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestedByUserId: userId ?? null,
transitAssigneeRequestNote: note?.trim() || null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
return updated;
}
/**
* GL Djibouti names the transit officer — free text, because the person is
* not a platform user. Answering unblocks the declaration for Ethiopia. A
* later call overwrites the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(
contractId: string,
assignee: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: assignee.trim(),
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
return updated;
}
private async ensureDeclarationPrerequisites(
contractId: string,
contract: Contract,
@@ -978,6 +1201,16 @@ export class ContractClearanceService {
'All required customer documents must be approved before uploading a declaration.',
);
}
// The transit officer must be named by Djibouti first — the declaration is
// filed against whoever will physically handle the shipment there.
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.transitAssigneeName) {
throw new BadRequestException(
cycle?.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
);
}
const milestones = await this.workflowService.listMilestones(contractId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
@@ -1084,6 +1317,67 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/**
* The customer disagrees with the advised duty & tax and asks GL Ethiopia to
* correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
* step becomes actionable again on the GL clearance page, with the customer's
* message shown beside it. GL re-advises (same endpoint as the first time),
* which closes the dispute — the loop may run as many rounds as it takes.
*/
async disputeDuty(
contractId: string,
note: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty applies only to import contracts.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what is wrong with the advised amount so GL can correct it.',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
const milestones = await this.workflowService.listMilestones(contractId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
throw new BadRequestException(
'There is no advised duty amount to dispute yet.',
);
}
// Once the slip is in, the money is paid — a dispute then is a refund
// conversation, not a re-advice.
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
throw new BadRequestException(
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
);
}
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'DUTY_DISPUTE',
userId,
'CUSTOMER',
);
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
// (the stepper picks its active step from milestone completion).
await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtOutput,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.dutyDisputed(updated, note.trim());
return updated;
}
async uploadDutySlip(
contractId: string,
file: Express.Multer.File,
@@ -1198,7 +1492,7 @@ export class ContractClearanceService {
contractId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
@@ -1208,6 +1502,8 @@ export class ContractClearanceService {
if (!file) throw new BadRequestException('No Delivery Order uploaded');
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
@@ -1219,9 +1515,10 @@ export class ContractClearanceService {
});
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle && vesselDepartureDate?.trim()) {
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate: vesselDepartureDate.trim(),
vesselArrivalDate,
doCollectedDate,
});
}
if (cycle?.preClearanceFinalizedAt) {

View File

@@ -1,3 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { Contract } from './entities/contract.entity';
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
@@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): {
includesCustoms,
};
}
/**
* Djibouti GL cannot record a Delivery Order without saying WHEN the vessel
* arrived and WHEN the DO was collected — the file alone leaves the import
* timeline unauditable. Shared by the contract and per-booking DO uploads so
* one endpoint can never be laxer than the other.
*
* Returns the normalized `YYYY-MM-DD` pair; throws if either is missing,
* unparseable, or the DO predates the vessel's arrival.
*/
export function assertDoCollectionDates(dates?: {
vesselArrivalDate?: string;
doCollectedDate?: string;
}): { vesselArrivalDate: string; doCollectedDate: string } {
const vesselArrivalDate = normalizeDoDate(
dates?.vesselArrivalDate,
'Vessel arrival date',
);
const doCollectedDate = normalizeDoDate(
dates?.doCollectedDate,
'DO collected date',
);
if (doCollectedDate < vesselArrivalDate) {
throw new BadRequestException(
'DO collected date cannot be earlier than the vessel arrival date.',
);
}
return { vesselArrivalDate, doCollectedDate };
}
/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */
function normalizeDoDate(value: string | undefined, label: string): string {
const trimmed = value?.trim();
if (!trimmed) {
throw new BadRequestException(`${label} is required to upload a Delivery Order.`);
}
const date = trimmed.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
throw new BadRequestException(`${label} is not a valid date.`);
}
return date;
}

View File

@@ -25,7 +25,18 @@ export type ContractDocumentChange =
toOrder: number;
}
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
/**
* A contract field (not a document article) changed — the customer editing a
* DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type.
*/
| {
kind: 'FIELD_CHANGED';
field: string;
label: string;
from: string | null;
to: string | null;
};
type SnapshotLike = Pick<
ContractDocumentSnapshot,
@@ -134,6 +145,60 @@ export function diffSnapshots(
return changes;
}
/** Human label per audited contract field, in the order they read on the form. */
export const CONTRACT_FIELD_LABELS: Record<string, string> = {
contractKind: 'Contract kind',
tradeDirection: 'Trade direction',
freightType: 'Freight type',
serviceType: 'Service type',
paymentCurrency: 'Payment currency',
contractType: 'Contract type',
isHazardous: 'Hazardous',
hazardClass: 'Hazard class',
unNumber: 'UN number',
isReefer: 'Reefer',
equipmentReturn: 'Equipment return',
customsClearingAgent: 'Customs clearing agent',
firstMilePickupAddress: 'First-mile pickup address',
lastMileDeliveryAddress: 'Last-mile delivery address',
routes: 'Routes',
cargoScope: 'Cargo scope',
};
/** Render a field value for the audit trail — never "[object Object]". */
function displayValue(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
return String(value);
}
/**
* Compare two flat maps of contract fields and report what changed. Only keys
* present in `after` are considered, so a partial update never reports the
* fields it did not touch.
*/
export function diffContractFields(
before: Record<string, unknown>,
after: Record<string, unknown>,
): ContractDocumentChange[] {
const changes: ContractDocumentChange[] = [];
for (const [field, nextRaw] of Object.entries(after)) {
const next = displayValue(nextRaw);
const previous = displayValue(before[field]);
if (next === previous) continue;
changes.push({
kind: 'FIELD_CHANGED',
field,
label: CONTRACT_FIELD_LABELS[field] ?? field,
from: previous,
to: next,
});
}
return changes;
}
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
export function summarizeChanges(changes: ContractDocumentChange[]): string {
if (changes.length === 0) return 'No changes';
@@ -148,6 +213,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
const counts = new Map<string, number>();
const parts: string[] = [];
const fields: string[] = [];
for (const change of changes) {
const verb = articleVerbs[change.kind];
@@ -157,9 +223,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
parts.push('document title changed');
} else if (change.kind === 'WHEREAS_CHANGED') {
parts.push('recitals changed');
} else if (change.kind === 'FIELD_CHANGED') {
fields.push(change.label.toLowerCase());
}
}
if (fields.length > 0) {
parts.push(
fields.length <= 3
? `${fields.join(', ')} changed`
: `${fields.length} contract fields changed`,
);
}
const articleParts = [...counts.entries()].map(
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
);

View File

@@ -1,8 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
import {
ContractDocumentChange,
diffSnapshots,
summarizeChanges,
} from './contract-document-diff.util';
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
import type { ContractDocumentSnapshot } from './entities/contract.entity';
@@ -12,6 +16,39 @@ export interface RecordRevisionInput {
after: ContractDocumentSnapshot | null;
actorId?: string | null;
actorRole?: string | null;
actorName?: string | null;
stepId?: string | null;
}
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in the audit trail.
*/
interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === 'string' && name.trim()) return name.trim();
if (name && typeof name === 'object') {
const localized =
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/** Pre-computed changes (contract fields), rather than a document diff. */
export interface RecordChangesInput {
contractId: string;
changes: ContractDocumentChange[];
actorId?: string | null;
actorRole?: string | null;
actorName?: string | null;
stepId?: string | null;
}
@@ -22,6 +59,7 @@ export class ContractDocumentHistoryService {
constructor(
@InjectRepository(ContractDocumentRevision)
private readonly revisionRepo: Repository<ContractDocumentRevision>,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
/**
@@ -30,18 +68,32 @@ export class ContractDocumentHistoryService {
* and swallowed. A no-op edit records nothing.
*/
async record(input: RecordRevisionInput): Promise<void> {
return this.recordChanges({
...input,
changes: diffSnapshots(input.before, input.after),
});
}
/**
* Append a revision from an already-computed change set — the contract-field
* path, where there is no document snapshot to diff. Same best-effort
* contract as {@link record}: a no-op change set records nothing, and a
* failure here never breaks the edit that triggered it.
*/
async recordChanges(input: RecordChangesInput): Promise<void> {
try {
const changes = diffSnapshots(input.before, input.after);
if (changes.length === 0) return;
if (input.changes.length === 0) return;
await this.revisionRepo.save(
this.revisionRepo.create({
contractId: input.contractId,
actorId: input.actorId ?? null,
actorRole: input.actorRole ?? null,
actorName:
input.actorName ?? (await this.resolveActorName(input.actorId)),
stepId: input.stepId ?? null,
summary: summarizeChanges(changes),
changes,
summary: summarizeChanges(input.changes),
changes: input.changes,
}),
);
} catch (err) {
@@ -51,11 +103,62 @@ export class ContractDocumentHistoryService {
}
}
/**
* Name for the acting user. `iam.users` is owned by the auth system and has
* no entity here, so it is read directly; a miss is not an error — the trail
* still carries the id, role and timestamp.
*/
private async resolveActorName(
actorId?: string | null,
): Promise<string | null> {
if (!actorId) return null;
const names = await this.resolveActorNames([actorId]);
return names.get(actorId) ?? null;
}
/** Batched {@link resolveActorName} — one query for a whole revision list. */
private async resolveActorNames(
actorIds: string[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(actorIds.filter(Boolean))];
if (ids.length === 0) return resolved;
try {
const rows = (await this.dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
} catch (err) {
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
}
return resolved;
}
/** Revision history for a contract, newest first. */
list(contractId: string): Promise<ContractDocumentRevision[]> {
return this.revisionRepo.find({
async list(contractId: string): Promise<ContractDocumentRevision[]> {
const revisions = await this.revisionRepo.find({
where: { contractId },
order: { createdAt: 'DESC' },
});
// Rows written before actor_name existed still carry an actor_id — resolve
// those for display (one query for the whole list) rather than backfilling.
const missing = revisions
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
if (missing.length === 0) return revisions;
const names = await this.resolveActorNames(missing);
for (const revision of revisions) {
if (!revision.actorName && revision.actorId) {
revision.actorName = names.get(revision.actorId) ?? null;
}
}
return revisions;
}
}

View File

@@ -0,0 +1,164 @@
import { BadRequestException } from '@nestjs/common';
import { ContractClearanceService } from './contract-clearance.service';
import type { Contract } from './entities/contract.entity';
/**
* The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount;
* the customer either pays it or sends it back with a reason. Sending it back
* reopens the advice milestone — that is what puts the Duty & tax step back in
* GL's hands — and the round can repeat until the amount is agreed.
*/
describe('ContractClearanceService — duty dispute', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'ctr-1',
reference: 'CTR-2026-00042',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
...over,
}) as Contract;
const milestone = (code: string, status: string) =>
({ milestoneCode: code, status }) as never;
let repo: {
currentCycle: jest.Mock;
createReviewNote: jest.Mock;
updateCycle: jest.Mock;
findReviewNotes: jest.Mock;
};
let contractsService: { findById: jest.Mock };
let workflowService: { listMilestones: jest.Mock };
let milestoneService: { reopenForContract: jest.Mock };
let notifier: { dutyDisputed: jest.Mock };
let service: ContractClearanceService;
const build = (milestones: unknown[]) => {
workflowService.listMilestones.mockResolvedValue(milestones);
};
beforeEach(() => {
repo = {
currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
updateCycle: jest.fn().mockResolvedValue(undefined),
findReviewNotes: jest.fn().mockResolvedValue([]),
};
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
workflowService = { listMilestones: jest.fn().mockResolvedValue([]) };
milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) };
notifier = { dutyDisputed: jest.fn() };
service = new ContractClearanceService(
repo as never,
contractsService as never,
{} as never, // bookingsService
{} as never, // filesService
{} as never, // fileUploadSettingsService
workflowService as never,
milestoneService as never,
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'PENDING'),
]);
});
it('records the objection and hands the step back to GL', async () => {
await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1');
expect(repo.createReviewNote).toHaveBeenCalledWith(
'ctr-1',
'Declared value is wrong',
'DUTY_DISPUTE',
'user-1',
'CUSTOMER',
);
// Reopening the advice milestone is what re-arms the Duty & tax step.
expect(milestoneService.reopenForContract).toHaveBeenCalledWith(
'ctr-1',
'DUTY_TAXES_ADVISED',
);
expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', {
currentPhase: 'GL_ET_OUTPUT',
});
});
it('tells GL Ethiopia, not the customer', async () => {
await service.disputeDuty('ctr-1', 'Too high', 'user-1');
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ctr-1' }),
'Too high',
);
});
it('requires a reason — GL cannot correct an unexplained objection', async () => {
await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(milestoneService.reopenForContract).not.toHaveBeenCalled();
});
it('refuses when nothing has been advised yet', async () => {
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
/no advised duty amount/i,
);
});
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'COMPLETED'),
]);
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
/already been submitted/i,
);
});
it('refuses when duty was never required for this clearance', async () => {
repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false });
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
/not required/i,
);
});
describe('the view', () => {
const buildDispute = (milestones: unknown[]) =>
(
service as unknown as {
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
}
).buildDutyDispute('ctr-1', milestones);
it('shows the objection while GL still owes a corrected advice', async () => {
repo.findReviewNotes.mockResolvedValue([
{ body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
]);
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
});
it('clears itself once GL re-advises', async () => {
repo.findReviewNotes.mockResolvedValue([
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
]);
expect(dispute).toBeNull();
});
});
});

View File

@@ -0,0 +1,89 @@
import {
diffContractFields,
summarizeChanges,
} from './contract-document-diff.util';
/**
* The contract-field audit runs on the customer's own edits, so it has to be
* exact: never report a field the edit did not touch, and never render a value
* as "[object Object]" or "true" in the trail a reviewer reads.
*/
describe('diffContractFields', () => {
it('reports only the fields that actually changed', () => {
const changes = diffContractFields(
{ freightType: 'BULK', paymentCurrency: 'USD', isReefer: false },
{ freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false },
);
expect(changes).toEqual([
{
kind: 'FIELD_CHANGED',
field: 'freightType',
label: 'Freight type',
from: 'BULK',
to: 'CONTAINER',
},
]);
});
it('renders booleans as Yes/No, not true/false', () => {
const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true });
expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' });
});
it('treats null, undefined and empty string as "not set"', () => {
expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]);
expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]);
const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' });
expect(set).toMatchObject({ from: null, to: 'UN1234' });
});
it('ignores fields absent from the update', () => {
// A partial edit must not report the fields it never sent.
expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]);
});
it('records a route swap that keeps the same lane count', () => {
const [change] = diffContractFields(
{ routes: 'Nagad → Mojo' },
{ routes: 'Nagad → Adama' },
);
expect(change).toMatchObject({
label: 'Routes',
from: 'Nagad → Mojo',
to: 'Nagad → Adama',
});
});
it('summarises field changes by name, and by count once there are many', () => {
const few = diffContractFields(
{ freightType: 'BULK', paymentCurrency: 'USD' },
{ freightType: 'CONTAINER', paymentCurrency: 'ETB' },
);
expect(summarizeChanges(few)).toBe('freight type, payment currency changed');
const many = diffContractFields(
{ a: '1', b: '1', c: '1', d: '1' },
{ a: '2', b: '2', c: '2', d: '2' },
);
expect(summarizeChanges(many)).toBe('4 contract fields changed');
});
it('summarises document and field changes together', () => {
const summary = summarizeChanges([
{ kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' },
{
kind: 'FIELD_CHANGED',
field: 'routes',
label: 'Routes',
from: 'A → B',
to: 'A → C',
},
]);
expect(summary).toBe('1 article edited, routes changed');
});
});

View File

@@ -192,6 +192,56 @@ export class ContractNotifierService {
});
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(c: Contract, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for contract ${c.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(
c: Contract,
assignee: string,
previous: string | null,
): void {
const msg = previous
? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
});
}
/**
* The customer disputed the advised duty & tax. This goes to STAFF, not the
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
* page is where they do it.
*/
dutyDisputed(c: Contract, note: string): void {
const msg =
`The customer disputed the duty & tax advised on contract ${c.reference}: ` +
`"${note}". Review and re-advise the amount on the clearance page.`;
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
});
}
/** A clearance document was queried — customer must re-upload it. */
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
const msg =

View File

@@ -0,0 +1,115 @@
import { ContractDocumentHistoryService } from './contract-document-history.service';
/**
* `iam.users.name` is a localized jsonb object, not a string. Reading it
* naively puts "[object Object]" in the audit trail — or, worse, throws and
* leaves every revision anonymous. These specs pin the resolution rules.
*/
describe('ContractDocumentHistoryService actor names', () => {
const build = (rows: unknown[]) => {
const saved: Array<Record<string, unknown>> = [];
const service = Object.create(
ContractDocumentHistoryService.prototype,
) as ContractDocumentHistoryService;
Object.assign(service, {
logger: { warn: jest.fn(), error: jest.fn() },
dataSource: { query: jest.fn().mockResolvedValue(rows) },
revisionRepo: {
create: (row: Record<string, unknown>) => row,
save: jest.fn((row: Record<string, unknown>) => {
saved.push(row);
return Promise.resolve(row);
}),
find: jest.fn().mockResolvedValue([]),
},
});
return { service, saved };
};
const change = {
kind: 'FIELD_CHANGED' as const,
field: 'routes',
label: 'Routes',
from: 'A → B',
to: 'A → C',
};
it('prefers the English label from the localized name object', async () => {
const { service, saved } = build([
{ id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' },
]);
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
expect(saved[0].actorName).toBe('Super Admin');
});
it('falls back to another locale, then username, then email', async () => {
const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]);
await onlyAmharic.service.recordChanges({
contractId: 'c-1',
changes: [change],
actorId: 'u-1',
});
expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር');
const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]);
await noName.service.recordChanges({
contractId: 'c-1',
changes: [change],
actorId: 'u-1',
});
expect(noName.saved[0].actorName).toBe('operator');
const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]);
await emailOnly.service.recordChanges({
contractId: 'c-1',
changes: [change],
actorId: 'u-1',
});
expect(emailOnly.saved[0].actorName).toBe('o@edr.local');
});
it('never writes "[object Object]" as the actor name', async () => {
const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]);
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
expect(String(saved[0].actorName)).not.toContain('object Object');
});
it('records nothing when the change set is empty', async () => {
const { service, saved } = build([]);
await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' });
expect(saved).toHaveLength(0);
});
it('still records the revision when the user lookup fails', async () => {
const { service, saved } = build([]);
Object.assign(service, {
dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) },
});
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
expect(saved).toHaveLength(1);
expect(saved[0].actorName).toBeNull();
});
it('resolves names for legacy rows that predate the actor_name column', async () => {
const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]);
Object.assign(service, {
revisionRepo: {
find: jest
.fn()
.mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]),
},
});
const [revision] = await service.list('c-1');
expect(revision.actorName).toBe('Abenezer Haile');
});
});

View File

@@ -274,6 +274,20 @@ export class ContractTransitionService {
// shared six templates are never written here.
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
// Audit whatever staff changed in the accept dialog. The baseline is the
// template this contract would otherwise have frozen as-is, so an untouched
// accept diffs to nothing and records no revision.
if (documentSnapshot) {
const baseline = await this.resolveDocumentSnapshot(contract);
await this.documentHistory.record({
contractId,
before: baseline,
after: snapshot,
actorId,
actorRole: 'Reviewing staff',
});
}
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,

View File

@@ -303,8 +303,10 @@ export class ContractsController {
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDto,
@UploadedFiles() files: Express.Multer.File[],
// Recorded on the edit's audit revision — who changed the contract.
@CurrentUser() user?: TCurrentUser,
) {
return this.contractsService.update(id, dto, files ?? []);
return this.contractsService.update(id, dto, files ?? [], user?.id);
}
@Delete(':id')
@@ -738,6 +740,92 @@ export class ContractsController {
return this.clearanceService.finalizePreClearance(id);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration',
})
requestTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
assignee,
resolveAuthUserId(user),
);
}
@Get(':id/clearance/documents/:fileKey/versions')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({
summary:
'Version history of one clearance document — the customer original plus every staff replacement',
})
documentVersions(
@Param('id', ParseUUIDPipe) id: string,
@Param('fileKey') fileKey: string,
) {
return this.clearanceService.documentVersions(id, fileKey);
}
@Post(':id/clearance/documents/:fileKey/replace')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving',
})
replaceClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param('fileKey') fileKey: string,
@UploadedFile() file: Express.Multer.File,
@Body('reason') reason: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.replaceDocument(
id,
fileKey,
file,
resolveAuthUserId(user),
reason,
);
}
@Post(':id/clearance/duty/dispute')
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
})
disputeContractDuty(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user));
}
@Post(':id/clearance/duty-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@@ -766,19 +854,21 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
@ApiOperation({
summary:
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
})
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
vesselArrivalDate,
doCollectedDate,
});
}
@Post(':id/clearance/release-order')

View File

@@ -164,7 +164,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
'contract.files',
FileRecord,
'file',
"file.resource_id = contract.id AND file.resource = 'contracts'",
// Superseded versions are soft-deleted, not dropped — keep them out of
// the live file list (a manual join condition is not filtered for us).
"file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL",
)
.getOne();
@@ -558,6 +560,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
contractId: string,
noteType: ContractReviewNoteType,
): Promise<ContractReviewNote[]> {
return this.dataSource.getRepository(ContractReviewNote).find({
where: { contractId, noteType },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
contractId: string,
noteType?: ContractReviewNoteType,
@@ -725,12 +738,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
ContractClearanceCycle,
| 'dutyRequired'
| 'vesselDepartureDate'
| 'vesselArrivalDate'
| 'doCollectedDate'
| 'roAmendmentRequestedAt'
| 'roHoldReason'
| 'currentPhase'
| 'status'
| 'preClearanceFinalizedAt'
| 'completedAt'
| 'transitAssigneeRequestedAt'
| 'transitAssigneeRequestedByUserId'
| 'transitAssigneeRequestNote'
| 'transitAssigneeName'
| 'transitAssigneeAssignedAt'
| 'transitAssigneeAssignedByUserId'
>
>,
): Promise<void> {

View File

@@ -26,6 +26,8 @@ import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { diffContractFields } from './contract-document-diff.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -42,6 +44,35 @@ export interface PaginatedContracts {
};
}
/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
function describeRoutes(routes?: ContractRoute[]): string | null {
if (!routes?.length) return null;
return [...routes]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map(
(r) =>
`${r.originYard?.label ?? r.originYardId}${r.destinationYard?.label ?? r.destinationYardId}`,
)
.join(', ');
}
/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
if (!scope?.length) return null;
return scope
.map((row) => {
const label =
row.containerSize ??
row.cargoType?.cargoTypeName ??
row.cargoFreeText ??
row.cargoTypeId ??
'cargo';
return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
})
.sort()
.join(', ');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -57,6 +88,7 @@ export class ContractsService {
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly documentHistory: ContractDocumentHistoryService,
) {}
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
@@ -332,7 +364,11 @@ export class ContractsService {
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
// A contract is always QUOTED in USD — the billing currency is chosen per
// booking (or on the shipment request when GL books for the customer), so
// any client-supplied currency here is ignored. Contracts created before
// this rule keep whatever they stored; update() never rewrites it.
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn ?? null,
@@ -490,6 +526,7 @@ export class ContractsService {
id: string,
dto: UpdateContractDto,
files: Express.Multer.File[],
actorId?: string,
): Promise<{ contract: Contract; warnings: string[] }> {
const existing = await this.findById(id);
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
@@ -516,7 +553,9 @@ export class ContractsService {
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
freightType,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
// Never rewritten: grandfathered contracts keep the currency (and frozen
// snapshots) they were signed with.
paymentCurrency: existing.paymentCurrency,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
// Same rule as create: clearing the flag clears the declaration with it.
@@ -576,7 +615,52 @@ export class ContractsService {
existing.companyProfileId ?? null,
);
return { contract: await this.findById(id), warnings };
const updated = await this.findById(id);
// Audit what this edit actually changed. Runs after the writes so the
// "after" side is read back from the contract rather than from the DTO.
await this.recordFieldRevision(existing, updated, actorId);
return { contract: updated, warnings };
}
/** Fields worth auditing on a customer edit, read off a loaded contract. */
private auditableFields(contract: Contract): Record<string, unknown> {
return {
contractKind: contract.contractKind,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: contract.contractType,
isHazardous: contract.isHazardous,
hazardClass: contract.hazardClass,
unNumber: contract.unNumber,
isReefer: contract.isReefer,
equipmentReturn: contract.equipmentReturn,
customsClearingAgent: contract.customsClearingAgent,
firstMilePickupAddress: contract.firstMilePickupAddress,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
routes: describeRoutes(contract.routes),
cargoScope: describeCargoScope(contract.cargoScope),
};
}
/** Append a revision describing a customer's edit to the contract itself. */
private async recordFieldRevision(
before: Contract,
after: Contract,
actorId?: string,
): Promise<void> {
const changes = diffContractFields(
this.auditableFields(before),
this.auditableFields(after),
);
await this.documentHistory.recordChanges({
contractId: after.id,
changes,
actorId: actorId ?? null,
actorRole: 'Customer',
});
}
/** Parse comma-separated or repeated status query values. */

View File

@@ -0,0 +1,46 @@
import { BadRequestException } from '@nestjs/common';
import { assertDoCollectionDates } from './contract-clearance.util';
describe('assertDoCollectionDates', () => {
it('requires both dates', () => {
expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException);
expect(() =>
assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }),
).toThrow(/DO collected date is required/);
expect(() =>
assertDoCollectionDates({ doCollectedDate: '2026-07-01' }),
).toThrow(/Vessel arrival date is required/);
// Whitespace is not a date.
expect(() =>
assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }),
).toThrow(BadRequestException);
});
it('rejects a DO collected before the vessel arrived', () => {
expect(() =>
assertDoCollectionDates({
vesselArrivalDate: '2026-07-10',
doCollectedDate: '2026-07-09',
}),
).toThrow(/cannot be earlier than the vessel arrival date/);
});
it('normalizes an ISO datetime down to its date part', () => {
expect(
assertDoCollectionDates({
vesselArrivalDate: '2026-07-10T21:00:00.000Z',
doCollectedDate: '2026-07-10T05:00:00.000Z',
}),
).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' });
});
it('rejects a malformed date', () => {
expect(() =>
assertDoCollectionDates({
vesselArrivalDate: '10/07/2026',
doCollectedDate: '2026-07-10',
}),
).toThrow(/not a valid date/);
});
});

View File

@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
@@ -91,6 +92,15 @@ export class CreateBookingRequestDto {
@Type(() => RequestBulkLineDto)
bulk?: RequestBulkLineDto;
@ApiPropertyOptional({
enum: ['ETB', 'USD'],
description:
'Billing currency for the shipment GL will book. Intercity is always ETB.',
})
@IsOptional()
@IsIn(['ETB', 'USD'])
paymentCurrency?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -15,6 +15,8 @@ import {
ValidateNested,
} from 'class-validator';
import { PAYMENT_CURRENCIES } from './create-contract.dto';
/** Per-shipment equipment return — "NA" stays contract-level only. */
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
@@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto {
@IsUUID()
contractRouteId?: string;
/**
* The contract quotes in USD; the customer picks the billing currency here.
* Omitted → the contract's own currency (USD for contracts created under the
* current rule, the grandfathered currency for older ones). Intercity is
* forced to ETB by the service regardless of what is sent.
*/
@ApiPropertyOptional({
enum: PAYMENT_CURRENCIES,
description: 'Billing currency for this shipment. Intercity is always ETB.',
})
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',

View File

@@ -158,9 +158,20 @@ export class CreateContractDto {
@IsUUID()
serviceTypeId!: string;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
/**
* Deprecated at the contract level. A contract now always quotes in USD; the
* customer picks the billing currency per booking (or on the shipment request
* when GL books on their behalf). Accepted but ignored on create so older
* clients don't break — the service forces USD.
*/
@ApiPropertyOptional({
enum: PAYMENT_CURRENCIES,
deprecated: true,
description: 'Ignored — contracts always quote in USD. Choose currency at booking.',
})
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
paymentCurrency?: string;
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
@IsOptional()

View File

@@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity {
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
requestedLines!: Freight.RequestedShipmentLines;
/**
* Billing currency the customer chose for this shipment. The contract quotes
* in USD; on a customs contract GL creates the booking, so this is where the
* customer states which currency to be invoiced in.
*/
@Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true })
paymentCurrency?: string | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;

View File

@@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity {
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
vesselDepartureDate?: string | null;
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
vesselArrivalDate?: string | null;
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
doCollectedDate?: string | null;
/**
* Transit-assignee handshake that runs BEFORE the customs declaration: GL
* Ethiopia asks Djibouti for the officer who will handle the shipment in
* transit, and Djibouti answers with a name. The declaration step stays shut
* until `transitAssigneeName` is set; Djibouti may overwrite it later
* (reassignment) and the newer name simply wins.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true })
transitAssigneeRequestedByUserId?: string | null;
/** What GL Ethiopia asked for — shown on the Djibouti queue. */
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
/** The officer Djibouti named — free text, no user directory to bind to. */
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
@Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true })
transitAssigneeAssignedByUserId?: string | null;
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
roAmendmentRequestedAt?: Date | null;

View File

@@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity {
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/**
* Who made the edit, captured at the time. Denormalised so the trail still
* names them after a rename or a deactivated account.
*/
@Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true })
actorName?: string | null;
/** The approval step's required role at the time of the edit. */
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
actorRole?: string | null;

View File

@@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
'STAFF_NOTE',
'CUSTOMER_NOTE',
'AMENDMENT',
/**
* The customer disputed the advised duty & tax and asked GL Ethiopia to
* correct it. One row per round — the advice/dispute loop can repeat.
*/
'DUTY_DISPUTE',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -0,0 +1,85 @@
import { ContractBookingService } from './contract-booking.service';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import type { Contract } from './entities/contract.entity';
import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
const contract = (over: Partial<Contract>): Contract =>
({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract;
/** The private resolver, reached without standing up the whole Nest graph. */
const resolveCurrency = (c: Contract, requested?: string | null): string =>
(
ContractBookingService.prototype as unknown as {
resolveShipmentCurrency: (c: Contract, r?: string | null) => string;
}
).resolveShipmentCurrency(c, requested);
const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot;
const frozenByCode = (
snap: ContractRateSnapshot | null,
bookingCurrency: string,
usdToEtb: number,
): ContractRateSnapshot | null =>
(
BookingPricingService.prototype as unknown as {
frozenRateByCode: (
m: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
usdToEtb: number,
) => ContractRateSnapshot | null;
}
).frozenRateByCode(
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
'CONTAINER_20FT',
bookingCurrency,
usdToEtb,
);
describe('per-shipment billing currency', () => {
it('takes the customer choice over the contract', () => {
expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB');
expect(resolveCurrency(contract({}), 'USD')).toBe('USD');
});
it('falls back to the contract currency when none is chosen', () => {
// Grandfathered ETB contract with no explicit choice.
expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB');
expect(resolveCurrency(contract({}), ' ')).toBe('USD');
});
it('forces ETB on intercity whatever was requested', () => {
const domestic = contract({ tradeDirection: 'DOMESTIC' });
expect(resolveCurrency(domestic, 'USD')).toBe('ETB');
expect(resolveCurrency(domestic)).toBe('ETB');
});
});
describe('frozen contract rate in the booking currency', () => {
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
// The old behaviour returned null here, which silently re-priced the
// booking at live rates and lost the agreed contract price.
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
});
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
});
it('passes a matching-currency snapshot through untouched', () => {
const snap = snapshot('USD', 400);
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
});
it('refuses to price off an unusable exchange rate', () => {
// Converting with 0 would zero the whole line.
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
});
it('returns null when there is no snapshot', () => {
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
});
});

View File

@@ -0,0 +1,175 @@
import { BadRequestException } from '@nestjs/common';
import { ContractClearanceService } from './contract-clearance.service';
import type { Contract } from './entities/contract.entity';
/**
* Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will
* handle the shipment in transit; Djibouti answers with a name. The customs
* declaration stays shut until that name exists, and Djibouti may send a
* different one later.
*/
describe('ContractClearanceService — transit assignee', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'ctr-1',
reference: 'CTR-2026-00042',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
...over,
}) as Contract;
let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock };
let contractsService: { findById: jest.Mock };
let notifier: {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
id: 'cyc-1',
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
...over,
});
beforeEach(() => {
repo = {
currentCycle: jest.fn().mockResolvedValue(cycle()),
updateCycle: jest.fn().mockResolvedValue(undefined),
};
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
notifier = {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
notifier as never,
);
});
describe('request (GL Ethiopia)', () => {
it('stamps the ask and pings Djibouti', async () => {
await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date);
expect(patch.transitAssigneeRequestedByUserId).toBe('et-1');
expect(patch.transitAssigneeRequestNote).toBe(
'Reefer, needs a cold-chain officer',
);
expect(notifier.transitAssigneeRequested).toHaveBeenCalled();
});
});
describe('assign (GL Djibouti)', () => {
it('records the officer and tells Ethiopia they can proceed', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ctr-1' }),
'Ahmed Bourhan',
null,
);
});
it('reassigns, carrying the previous name into the notice', async () => {
repo.currentCycle.mockResolvedValue(
cycle({
transitAssigneeRequestedAt: new Date(),
transitAssigneeName: 'Ahmed Bourhan',
}),
);
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
'Fatouma Ali',
'Ahmed Bourhan',
);
});
it('refuses an empty name', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await expect(
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
).rejects.toThrow(/not requested/i);
});
});
describe('declaration gate', () => {
const ensure = (c: Contract) =>
(
service as unknown as {
ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise<void>;
}
).ensureDeclarationPrerequisites('ctr-1', c);
beforeEach(() => {
// Documents are approved; only the assignee decides the outcome here.
(
service as unknown as { isClearanceFullyApproved: unknown }
).isClearanceFullyApproved = jest.fn().mockResolvedValue(true);
});
it('tells GL to raise the request when none exists', async () => {
await expect(ensure(contract())).rejects.toThrow(
/Request a transit assignee/i,
);
});
it('tells GL to wait when Djibouti has not answered', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await expect(ensure(contract())).rejects.toThrow(/has not assigned/i);
});
it('lets the declaration through once the officer is named', async () => {
repo.currentCycle.mockResolvedValue(
cycle({
transitAssigneeRequestedAt: new Date(),
transitAssigneeName: 'Ahmed Bourhan',
}),
);
(
service as unknown as { workflowService: unknown }
).workflowService = {
listMilestones: jest
.fn()
.mockResolvedValue([
{ milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' },
]),
};
await expect(ensure(contract())).resolves.toBeUndefined();
});
});
});

View File

@@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity {
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
reviewedAt!: Date | null;
/**
* Who replaced this version, when a newer file took its place. Superseded
* versions are soft-deleted rather than dropped, so the original a customer
* uploaded survives a staff correction and the two can be compared.
*/
@Column({ name: "replaced_by_user_id", type: "uuid", nullable: true })
replacedByUserId!: string | null;
/** Why the file was replaced — shown on the document's version history. */
@Column({ name: "replace_reason", type: "text", nullable: true })
replaceReason!: string | null;
}

View File

@@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository<FileRecord> {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
/**
* Retire the live version(s) of a document code. SOFT delete on purpose: the
* bytes and the row stay so the original upload can still be read back from
* the version history after staff replace it. Every normal read already
* filters soft-deleted rows, so callers see only the current version.
*
* `replacedBy` / `reason` are stamped on the retired row when a newer file is
* taking its place (as opposed to a plain removal).
*/
async deleteByCode(
resourceId: string,
resource: string,
code: string,
replacedBy?: { userId?: string | null; reason?: string | null },
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
if (replacedBy) {
await this.repository.update(
{ resourceId, resource, code },
{
replacedByUserId: replacedBy.userId ?? null,
replaceReason: replacedBy.reason ?? null,
},
);
}
await this.repository.softDelete({ resourceId, resource, code });
}
/**
* Every version of one document code, newest first — superseded versions
* included. The only read that deliberately looks past the soft-delete filter.
*/
findVersionHistory(
resourceId: string,
resource: string,
code: string,
): Promise<FileRecord[]> {
return this.repository.find({
where: { resourceId, resource, code },
withDeleted: true,
order: { createdAt: "DESC" },
});
}
/**

View File

@@ -0,0 +1,112 @@
import { FilesService } from './files.service';
/**
* Replacing a stored document must never destroy the previous one: the customer
* uploaded it, and a staff correction has to stay auditable against it. The old
* row is soft-deleted (so every normal read still returns exactly the current
* version) and stamped with who replaced it and why.
*/
describe('FilesService — document versions', () => {
const file = {
originalname: 'bill-of-lading.pdf',
size: 1234,
mimetype: 'application/pdf',
buffer: Buffer.from('x'),
} as Express.Multer.File;
let filesRepository: {
deleteByCode: jest.Mock;
create: jest.Mock;
findVersionHistory: jest.Mock;
};
let service: FilesService;
beforeEach(() => {
filesRepository = {
deleteByCode: jest.fn().mockResolvedValue(undefined),
create: jest.fn(async (row) => ({ id: 'file-new', ...row })),
findVersionHistory: jest.fn().mockResolvedValue([]),
};
service = new FilesService(
filesRepository as never,
{
uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'),
getObjectNameFromUrl: (u: string) => u,
getSignedUrl: jest.fn(),
} as never,
);
});
it('stamps the retired version with who replaced it and why', async () => {
await service.upsertByCode(
{ resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file },
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
);
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
'ctr-1',
'contracts',
'bill_of_lading',
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
);
});
it('still replaces silently when no replacer is given (system overwrites)', async () => {
await service.upsertByCode({
resourceId: 'ctr-1',
resource: 'contracts',
code: 'contract_pdf',
file,
});
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
'ctr-1',
'contracts',
'contract_pdf',
undefined,
);
});
it('marks the live row current and the soft-deleted ones superseded', async () => {
filesRepository.findVersionHistory.mockResolvedValue([
{
id: 'v2',
name: 'corrected.pdf',
url: 'u2',
size: 2,
mimeType: 'application/pdf',
createdAt: new Date('2026-07-20T10:00:00Z'),
deletedAt: null,
replacedByUserId: null,
replaceReason: null,
},
{
id: 'v1',
name: 'original.pdf',
url: 'u1',
size: 1,
mimeType: 'application/pdf',
createdAt: new Date('2026-07-18T10:00:00Z'),
deletedAt: new Date('2026-07-20T10:00:00Z'),
replacedByUserId: 'gl-user-1',
replaceReason: 'Wrong page order',
},
]);
const versions = await service.versionHistory(
'ctr-1',
'contracts',
'bill_of_lading',
);
expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null });
expect(versions[1]).toMatchObject({
id: 'v1',
isCurrent: false,
replacedByUserId: 'gl-user-1',
replaceReason: 'Wrong page order',
});
// The customer's original is still readable — that is the whole point.
expect(versions[1].url).toBe('u1');
});
});

View File

@@ -104,13 +104,66 @@ export class FilesService {
});
}
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who
* swapped it and why, which is what the version history shows.
*/
async upsertByCode(
input: CreateFileInput,
replacedBy?: { userId?: string | null; reason?: string | null },
): Promise<FileRecord> {
const { resourceId, resource, code } = input;
await this.filesRepository.deleteByCode(resourceId, resource, code);
await this.filesRepository.deleteByCode(
resourceId,
resource,
code,
replacedBy,
);
return this.upload(input);
}
/**
* Every stored version of one document, newest first. `isCurrent` marks the
* live row; the rest are superseded uploads kept for audit.
*/
async versionHistory(
resourceId: string,
resource: string,
code: string,
): Promise<
Array<{
id: string;
name: string;
url: string;
size: number;
mimeType: string;
uploadedAt: string;
isCurrent: boolean;
replacedAt: string | null;
replacedByUserId: string | null;
replaceReason: string | null;
}>
> {
const rows = await this.filesRepository.findVersionHistory(
resourceId,
resource,
code,
);
return rows.map((row) => ({
id: row.id,
name: row.name,
url: row.url,
size: row.size,
mimeType: row.mimeType,
uploadedAt: row.createdAt.toISOString(),
isCurrent: row.deletedAt == null,
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
replacedByUserId: row.replacedByUserId,
replaceReason: row.replaceReason,
}));
}
async deleteByCode(
resourceId: string,
resource: string,

View File

@@ -0,0 +1,237 @@
import {
Badge,
Button,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Eye, History, Upload } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { isViewable } from "@edr/ui-common";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
export interface ClearanceDocumentVersionsModalProps {
contractId: string;
/** The document being inspected; null closes the modal. */
doc: { fileKey: string; label: string } | null;
onClose: () => void;
/** Hide the replace form (finalized clearance, read-only viewers). */
canReplace?: boolean;
onReplaced?: () => void;
onView?: (file: { name: string; url: string }) => void;
}
const fmt = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/**
* Version history of one clearance document, and the way to add a version.
*
* Staff can correct a document without bouncing it back to the customer, but
* the customer's original is never overwritten — it drops down this list as a
* superseded version, stamped with who replaced it and why. The corrected file
* comes back unreviewed, so it still has to be approved before finalizing.
*/
export function ClearanceDocumentVersionsModal({
contractId,
doc,
onClose,
canReplace = false,
onReplaced,
onView,
}: ClearanceDocumentVersionsModalProps) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const { data: versions = [], isLoading } = useQuery({
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
queryFn: () =>
contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey),
enabled: Boolean(doc),
});
const replace = useMutation({
mutationFn: () =>
contractsService.replaceClearanceDocument(
contractId,
doc!.fileKey,
file!,
reason.trim(),
),
onSuccess: async () => {
toast.success("Document replaced — the previous version is kept on file");
setFile(null);
setReason("");
await queryClient.invalidateQueries({
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
});
await queryClient.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
});
onReplaced?.();
},
});
const close = () => {
setFile(null);
setReason("");
onClose();
};
return (
<Modal
opened={Boolean(doc)}
onClose={close}
size="lg"
radius="md"
title={
<Group gap={8}>
<History size={16} />
<Text fw={700}>{doc?.label ?? "Document"} version history</Text>
</Group>
}
>
<Stack gap="md">
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : versions.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing uploaded under this document yet.
</Text>
) : (
<Stack gap={8}>
{versions.map((v, index) => (
<Paper
key={v.id}
withBorder
radius="md"
p="sm"
bg={v.isCurrent ? "var(--mantine-color-edr-green-0)" : undefined}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{v.name}
</Text>
{v.isCurrent ? (
<Badge size="xs" color="edr-green" variant="light">
Current
</Badge>
) : index === versions.length - 1 ? (
<Badge size="xs" color="blue" variant="light">
Original
</Badge>
) : (
<Badge size="xs" color="gray" variant="light">
Superseded
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
Uploaded {fmt(v.uploadedAt)}
{v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""}
</Text>
{v.replaceReason ? (
<Text size="xs" c="orange.8">
Reason: {v.replaceReason}
</Text>
) : null}
</Stack>
<Group gap={6} wrap="nowrap">
{isViewable({ name: v.name, url: "" }) && onView ? (
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(v.id, v.name).then(onView)
}
>
View
</Button>
) : null}
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Download size={13} />}
onClick={() => void downloadBookingFile(v.id, v.name)}
>
Download
</Button>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
{canReplace ? (
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text size="sm" fw={700}>
Replace this document
</Text>
<Text size="xs" c="dimmed">
Use this for a correction you can make yourself. The customer&apos;s
copy stays in the history above, and the new file has to be
approved before clearance is finalized.
</Text>
<PhasedFileDropzone
label="Corrected document"
value={file}
onChange={setFile}
replaceMode
/>
<Textarea
label="Why is it being replaced?"
placeholder="e.g. customer sent page 2 only — attached the full signed copy"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
loading={replace.isPending}
disabled={!file || !reason.trim()}
onClick={() => replace.mutate()}
>
Replace document
</Button>
</Group>
</Stack>
</Paper>
) : null}
</Stack>
</Modal>
);
}
export default ClearanceDocumentVersionsModal;

View File

@@ -24,6 +24,7 @@ import {
Eye,
FileCheck2,
FileText,
History,
MessageSquareWarning,
Upload,
UserCheck,
@@ -31,6 +32,7 @@ import {
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { ClearanceDocumentVersionsModal } from "@/components/contracts/ClearanceDocumentVersionsModal";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
@@ -114,6 +116,11 @@ export function ContractClearanceReviewSection({
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
// Document whose version history is open (also hosts the replace form).
const [historyDoc, setHistoryDoc] = useState<{
fileKey: string;
label: string;
} | null>(null);
const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
@@ -264,6 +271,9 @@ export function ContractClearanceReviewSection({
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
}
onView={view}
onHistory={() =>
setHistoryDoc({ fileKey: doc.fileKey, label: doc.label })
}
busy={reviewDocument.isPending}
/>
))
@@ -487,6 +497,18 @@ export function ContractClearanceReviewSection({
</Group>
</Paper>
)}
{/* Version history + in-place correction. Replacing is blocked in the same
situations queries are (finalized clearance / read-only audit view) —
documents must not move once the gate has closed. */}
<ClearanceDocumentVersionsModal
contractId={contractId}
doc={historyDoc}
canReplace={!readOnly && !queriesLocked}
onClose={() => setHistoryDoc(null)}
onReplaced={onChanged}
onView={view}
/>
{viewer}
</Stack>
);
@@ -534,6 +556,7 @@ function DocReviewCard({
onApprove,
onQuery,
onView,
onHistory,
busy,
}: {
doc: Freight.ContractClearanceDocument;
@@ -548,6 +571,7 @@ function DocReviewCard({
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
onHistory: () => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
@@ -660,6 +684,21 @@ function DocReviewCard({
</Button>
</Tooltip>
)}
{/* Every version ever stored under this key — the customer original
plus any staff correction — and where a correction is made. */}
{hasFile && (
<Tooltip label="Versions & replace">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<History size={13} />}
onClick={onHistory}
>
History
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { History } from "lucide-react";
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { History, User } from "lucide-react";
import {
Avatar,
Badge,
Group,
Loader,
Stack,
Text,
Timeline,
Tooltip,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -8,6 +17,8 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
interface ContractRevisionTimelineProps {
contractId: string;
/** Rendered as a plain block instead of a SectionCard (own-tab layout). */
bare?: boolean;
}
type Change = Freight.IContractDocumentChange;
@@ -21,8 +32,26 @@ const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> =
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
FIELD_CHANGED: { color: "orange", label: "Field" },
};
/** Initials for the actor avatar — "Abenezer Haile" → "AH". */
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
/** Role slugs arrive like "-marketing-director-"; render them readably. */
function prettyRole(role: string): string {
const cleaned = role.replace(/^-+|-+$/g, "").replace(/[-_]+/g, " ").trim();
if (!cleaned) return role;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
}
/** What the change applies to — an article title, or the document itself. */
function changeSubject(change: Change): string {
switch (change.kind) {
@@ -40,6 +69,8 @@ function changeSubject(change: Change): string {
return `${change.fromTitle}” → “${change.title}`;
case "ARTICLE_REORDERED":
return `${change.title} (${change.fromOrder}${change.toOrder})`;
case "FIELD_CHANGED":
return `${change.label}: ${change.from ?? "—"}${change.to ?? "—"}`;
default:
return change.title;
}
@@ -53,21 +84,40 @@ function formatWhen(iso: string): string {
});
}
/** "3 hours ago" — the at-a-glance read; the exact stamp sits beside it. */
function formatAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, secondsPerUnit] of units) {
if (seconds >= secondsPerUnit) {
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
}
}
return "just now";
}
/**
* Audit trail of edits to the contract document. The document stays editable
* through the approval chain, so this is the record of who changed what.
*/
export function ContractRevisionTimeline({
contractId,
bare = false,
}: ContractRevisionTimelineProps) {
const { data: revisions, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-revisions"],
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
});
return (
<SectionCard icon={History} title="Document history">
{isLoading ? (
const body = isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
@@ -75,32 +125,56 @@ export function ContractRevisionTimeline({
</Text>
</Group>
) : !revisions?.length ? (
<Text size="sm" c="dimmed">
No edits recorded yet. Changes made to the contract articles during
approval will appear here.
<Stack gap={4} align="center" py="xl">
<History size={26} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
No edits recorded yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={420}>
Every change to this contract its articles during review and approval,
or its details while the customer can still edit it is logged here
with who made it and when.
</Text>
</Stack>
) : (
<Timeline
active={revisions.length}
bulletSize={18}
bulletSize={28}
lineWidth={2}
color="edr-green"
>
{revisions.map((revision) => (
{revisions.map((revision) => {
const who = revision.actorName?.trim();
const role = revision.actorRole ? prettyRole(revision.actorRole) : null;
return (
<Timeline.Item
key={revision.id}
bullet={
<Avatar size={26} radius="xl" color="edr-green" variant="light">
<Text size="10px" fw={700}>
{who ? initials(who) : <User size={13} />}
</Text>
</Avatar>
}
title={
<Group gap="xs" wrap="nowrap">
<Group gap="xs" wrap="wrap" align="baseline">
<Text size="sm" fw={600}>
{revision.actorRole ?? "Staff"}
{who ?? role ?? "Unknown user"}
</Text>
{role && who && (
<Badge size="xs" variant="light" color="gray" radius="sm">
{role}
</Badge>
)}
<Tooltip label={formatWhen(revision.createdAt)} withArrow>
<Text size="xs" c="dimmed">
{formatWhen(revision.createdAt)}
{formatAgo(revision.createdAt)}
</Text>
</Tooltip>
</Group>
}
>
<Stack gap={6} mt={4}>
<Stack gap={6} mt={6} pb="xs">
{revision.summary && (
<Text size="xs" c="dimmed">
{revision.summary}
@@ -126,9 +200,15 @@ export function ContractRevisionTimeline({
})}
</Stack>
</Timeline.Item>
))}
);
})}
</Timeline>
)}
);
if (bare) return body;
return (
<SectionCard icon={History} title="Change history">
{body}
</SectionCard>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Group } from "@mantine/core";
import { DateInput } from "@mantine/dates";
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
export function toIsoDate(value: Date | null): string | null {
if (!value) return null;
const tz = value.getTimezoneOffset() * 60000;
return new Date(value.getTime() - tz).toISOString().slice(0, 10);
}
export interface DoCollectionDates {
vesselArrival: Date | null;
doCollected: Date | null;
}
/** Both dates present and the DO not collected before the vessel docked. */
export function doDatesComplete(dates: DoCollectionDates): boolean {
if (!dates.vesselArrival || !dates.doCollected) return false;
return (toIsoDate(dates.doCollected) ?? "") >= (toIsoDate(dates.vesselArrival) ?? "");
}
/**
* The two mandatory dates Djibouti GL records with a Delivery Order. Shared by
* the DO upload modal and the inline DO step so neither surface can post an
* upload the API will reject.
*/
export function DoCollectionDateFields({
value,
onChange,
}: {
value: DoCollectionDates;
onChange: (next: DoCollectionDates) => void;
}) {
// Only complain once the user has actually entered the earlier date.
const outOfOrder =
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
return (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
label="Vessel arrival date"
placeholder="Select date"
value={value.vesselArrival}
onChange={(v) =>
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
}
maxDate={new Date()}
size="sm"
required
withAsterisk
/>
<DateInput
label="DO collected date"
placeholder="Select date"
value={value.doCollected}
onChange={(v) =>
onChange({ ...value, doCollected: v ? new Date(v) : null })
}
minDate={value.vesselArrival ?? undefined}
maxDate={new Date()}
size="sm"
required
withAsterisk
error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined}
/>
</Group>
);
}
/** Local state helper — both surfaces need the same seed-from-server logic. */
export function useDoCollectionDates(seed?: {
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
}) {
return useState<DoCollectionDates>(() => ({
vesselArrival: seed?.vesselArrivalDate ? new Date(seed.vesselArrivalDate) : null,
doCollected: seed?.doCollectedDate ? new Date(seed.doCollectedDate) : null,
}));
}

View File

@@ -4,6 +4,12 @@ import { DateInput } from "@mantine/dates";
import { Ship, Upload } from "lucide-react";
import toast from "react-hot-toast";
import {
DoCollectionDateFields,
doDatesComplete,
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { contractsService } from "@/services/contracts.service";
@@ -20,6 +26,9 @@ export interface GlClearanceUploadModalProps {
isBooking: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
vesselDepartureDate?: string | null;
/** Previously recorded DO dates, so a replace opens pre-filled. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
onSuccess?: () => void;
onPreview?: (file: { name: string; url: string }) => void;
}
@@ -32,6 +41,8 @@ export function GlClearanceUploadModal({
isBooking,
workflowFiles = [],
vesselDepartureDate,
vesselArrivalDate,
doCollectedDate,
onSuccess,
onPreview,
}: GlClearanceUploadModalProps) {
@@ -39,6 +50,10 @@ export function GlClearanceUploadModal({
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
// Earliest selectable vessel date (today, local) — refreshed on each open.
const todayISODate = useMemo(() => {
@@ -65,15 +80,22 @@ export function GlClearanceUploadModal({
toast.error("Vessel departure date is required.");
return;
}
if (isDo && !doDatesComplete(doDates)) {
toast.error("Vessel arrival date and DO collected date are both required.");
return;
}
setLoading(true);
try {
if (isDo) {
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, iso);
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
@@ -113,7 +135,7 @@ export function GlClearanceUploadModal({
<Stack gap="md">
<Text size="sm" c="dimmed">
{isDo
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
: "Upload the Release Order and confirm the vessel departure date."}
</Text>
@@ -127,14 +149,7 @@ export function GlClearanceUploadModal({
required
/>
) : (
<DateInput
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
clearable
/>
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
)}
<PhasedFileDropzone
@@ -154,7 +169,11 @@ export function GlClearanceUploadModal({
<Button
color="edr-green"
loading={loading}
disabled={!file || (isRo && !vesselDate)}
disabled={
!file ||
(isRo && !vesselDate) ||
(isDo && !doDatesComplete(doDates))
}
leftSection={<Upload size={16} />}
onClick={() => void submit()}
>

View File

@@ -19,6 +19,7 @@ import {
Loader,
Modal,
Paper,
SegmentedControl,
Select,
Stack,
Switch,
@@ -269,6 +270,9 @@ export default function GlCreateBookingForm() {
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// The customer states the billing currency on their shipment request — GL
// books in it. Intercity is always ETB (the API enforces this too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -439,6 +443,9 @@ export default function GlCreateBookingForm() {
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
@@ -834,6 +841,7 @@ export default function GlCreateBookingForm() {
const payload: Freight.CreateBookingUnderContractDto = {
...(contractRouteId ? { contractRouteId } : {}),
paymentCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
@@ -1672,6 +1680,30 @@ export default function GlCreateBookingForm() {
)}
<StepCard>
<Box mb="md">
<Text size="sm" fw={600} mb={4}>
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: bookingRequest?.paymentCurrency
? "Requested by the customer on their shipment request."
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/>
</Box>
<Textarea
label="Additional notes"
placeholder="Any special instructions for this shipment…"

View File

@@ -15,6 +15,7 @@ import {
TextInput,
} from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -24,6 +25,7 @@ import {
CheckCircle2,
Clock,
FileText,
MessageSquareWarning,
PackageCheck,
Receipt,
ShieldAlert,
@@ -37,6 +39,12 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
DoCollectionDateFields,
doDatesComplete,
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import {
findWorkflowFile,
PhasedUploadedFileRow,
@@ -53,6 +61,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "transitAssignee"
| "roHold"
| "roHoldReason"
| "milestones"
@@ -69,6 +79,8 @@ export type ClearanceViewLike = Pick<
| "offloaded"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
| "doCollectedDate"
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
@@ -332,8 +344,22 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!isBooking &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
contractId={entityId}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
@@ -504,6 +530,8 @@ export function PhasedClearanceActionPanel({
isBooking={isBooking}
workflowFiles={workflowFiles}
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
vesselArrivalDate={clearance.vesselArrivalDate}
doCollectedDate={clearance.doCollectedDate}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
@@ -1569,9 +1597,35 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
@@ -1765,6 +1819,8 @@ function DeliveryOrderStep({
onChanged,
workflowFiles = [],
replaceMode = false,
vesselArrivalDate,
doCollectedDate,
onViewFile,
onDownloadFile,
}: {
@@ -1773,12 +1829,18 @@ function DeliveryOrderStep({
onChanged?: () => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({
delivery_order: null,
});
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.delivery_order);
@@ -1790,20 +1852,27 @@ function DeliveryOrderStep({
workflowFiles={workflowFiles}
replaceMode={replaceMode}
loading={loading}
disabled={!hasFile}
helperText="Upload the Djibouti Delivery Order (DO) for this shipment."
disabled={!hasFile || !doDatesComplete(doDates)}
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
extraFields={
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async () => {
const file = files.delivery_order;
if (!file) return;
if (!file || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file);
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file);
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
setFiles({ delivery_order: null });
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");

View File

@@ -1,5 +1,6 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { FileText, Upload } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
@@ -11,6 +12,8 @@ export interface PhasedDocumentUploadFieldProps {
onChange: (key: string, file: File | null) => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
helperText: string;
/** Extra inputs rendered above the dropzones (e.g. required DO dates). */
extraFields?: ReactNode;
replaceMode?: boolean;
loading?: boolean;
disabled?: boolean;
@@ -27,6 +30,7 @@ export function PhasedDocumentUploadField({
onChange,
workflowFiles = [],
helperText,
extraFields,
replaceMode = false,
loading = false,
disabled = false,
@@ -87,6 +91,7 @@ export function PhasedDocumentUploadField({
</Group>
<Stack gap="md">
{extraFields}
{fields.map((f) => (
<PhasedFileDropzone
key={f.key}

View File

@@ -0,0 +1,246 @@
import type { Freight } from "@edr/types";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { contractsService } from "@/services/contracts.service";
export interface TransitAssigneePanelProps {
contractId: string;
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
/**
* ET asks and waits; DJ answers with a name. The same state renders from both
* desks — only the action on offer differs.
*/
side: "ET" | "DJ";
/** Hide the action (read-only audit view, or the user lacks the permission). */
readOnly?: boolean;
onChanged?: () => void;
}
const fmt = (iso?: string | null) =>
iso
? new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "—";
/**
* The transit-assignee handshake that gates the customs declaration.
*
* GL Ethiopia cannot file a declaration until Djibouti says who will handle the
* shipment on their side, so ET raises the ask here and Djibouti answers with a
* name (free text — the officer is not a platform user). Djibouti can send a
* different name later; the newest one wins and Ethiopia is notified again.
*/
export function TransitAssigneePanel({
contractId,
transitAssignee,
side,
readOnly = false,
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [changing, setChanging] = useState(false);
const request = useMutation({
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
onSuccess: () => {
toast.success("Request sent to GL Djibouti");
setNote("");
onChanged?.();
},
});
const assign = useMutation({
mutationFn: () =>
contractsService.assignTransitAssignee(contractId, assignee.trim()),
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
setChanging(false);
onChanged?.();
},
});
const requested = Boolean(transitAssignee?.requestedAt);
const assigned = Boolean(transitAssignee?.name);
// Settled state — both desks see the same line; DJ keeps a way to change it.
if (assigned && !changing) {
return (
<Alert
color="edr-green"
radius="md"
icon={<CheckCircle2 size={16} />}
title="Transit assignee confirmed"
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Text size="sm">
<Text span fw={700}>
{transitAssignee!.name}
</Text>{" "}
will handle this shipment in transit · assigned{" "}
{fmt(transitAssignee!.assignedAt)}
</Text>
{side === "DJ" && !readOnly ? (
<Button
size="compact-xs"
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setChanging(true);
}}
>
Change
</Button>
) : null}
</Group>
</Alert>
);
}
// Djibouti's desk: answer the ask.
if (side === "DJ") {
if (!requested) {
return (
<Alert color="gray" radius="md" icon={<Clock size={16} />}>
GL Ethiopia has not requested a transit assignee for this clearance yet.
</Alert>
);
}
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group gap={8}>
<UserCheck size={16} />
<Text fw={700} size="sm">
{changing ? "Change the transit assignee" : "Assign a transit officer"}
</Text>
<Badge size="xs" color="orange" variant="light">
Requested {fmt(transitAssignee?.requestedAt)}
</Badge>
</Group>
{transitAssignee?.requestNote ? (
<Text size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
label="Transit officer"
description="Name of the person handling this shipment in Djibouti"
placeholder="e.g. Ahmed Bourhan"
value={assignee}
onChange={(e) => setAssignee(e.currentTarget.value)}
disabled={readOnly}
/>
<Group justify="flex-end" gap="sm">
{changing ? (
<Button variant="default" radius="md" onClick={() => setChanging(false)}>
Cancel
</Button>
) : null}
<Button
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
onClick={() => assign.mutate()}
>
Send assignment
</Button>
</Group>
</Stack>
</Paper>
);
}
// Ethiopia's desk: raise the ask, then wait.
if (requested) {
return (
<Alert
color="orange"
radius="md"
icon={<Clock size={16} />}
title="Waiting for GL Djibouti"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
Requested {fmt(transitAssignee?.requestedAt)}. The customs declaration
opens once Djibouti names the transit officer.
</Text>
{!readOnly ? (
<Button
size="compact-xs"
variant="light"
color="orange"
radius="md"
loading={request.isPending}
onClick={() => request.mutate()}
>
Send a reminder
</Button>
) : null}
</Stack>
</Alert>
);
}
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group gap={8}>
<UserCheck size={16} />
<Text fw={700} size="sm">
Request a transit assignee
</Text>
</Group>
<Text size="xs" c="dimmed">
GL Djibouti must name the officer handling this shipment in transit
before the customs declaration can be filed.
</Text>
<Textarea
label="Note for GL Djibouti (optional)"
placeholder="Anything they need to know to pick the right officer"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
disabled={readOnly}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
loading={request.isPending}
disabled={readOnly}
onClick={() => request.mutate()}
>
Request assignee
</Button>
</Group>
</Stack>
</Paper>
);
}
export default TransitAssigneePanel;

View File

@@ -186,6 +186,16 @@ export const URL_CONSTANTS = {
CLEARANCE_QUEUE: "/contracts/clearance/queue",
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
/** Pre-declaration transit-assignee handshake (ET asks, DJ answers). */
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/contracts/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
`/contracts/${id}/clearance/transit-assignee/assign`,
/** Staff corrects a clearance document in place; the old version is kept. */
CLEARANCE_DOC_REPLACE: (id: string, fileKey: string) =>
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/replace`,
CLEARANCE_DOC_VERSIONS: (id: string, fileKey: string) =>
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/versions`,
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,

View File

@@ -14,6 +14,7 @@ import {
FileText,
Files,
Flame,
History,
LayoutGrid,
Package,
Receipt,
@@ -295,6 +296,8 @@ export default function ContractRequestDetailPage() {
? "documents"
: requestedTab === "customer"
? "customer"
: requestedTab === "history"
? "history"
: "details";
const customerLabel = contract.isGovernment
@@ -475,6 +478,9 @@ export default function ContractRequestDetailPage() {
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
</Tabs>
@@ -488,7 +494,6 @@ export default function ContractRequestDetailPage() {
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractRevisionTimeline contractId={contract.id} />
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
@@ -509,6 +514,14 @@ export default function ContractRequestDetailPage() {
/>
) : null}
</Stack>
) : currentTab === "history" ? (
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (

View File

@@ -38,6 +38,7 @@ import {
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
@@ -235,6 +236,23 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the customs declaration until this desk
names the officer handling the shipment in transit, so the ask
sits above everything else on the page. */}
{data.kind === "contract" ? (
<Box mb="md">
<TransitAssigneePanel
contractId={id!}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
) : null}
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
@@ -345,6 +363,8 @@ export default function GlClearanceDetailPage() {
isBooking={data.kind === "booking"}
workflowFiles={workflowFiles}
vesselDepartureDate={vesselDepartureDate}
vesselArrivalDate={data.clearance.vesselArrivalDate}
doCollectedDate={data.clearance.doCollectedDate}
onSuccess={() => void refetch()}
onPreview={view}
/>

View File

@@ -390,11 +390,12 @@ export const bookingsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});

View File

@@ -126,6 +126,23 @@ export interface SignContractPayload {
consentText?: string;
}
/**
* One stored version of a clearance document. The current row plus every
* superseded upload — staff corrections never erase what the customer sent.
*/
export interface ClearanceDocumentVersion {
id: string;
name: string;
url: string;
size: number;
mimeType: string;
uploadedAt: string;
isCurrent: boolean;
replacedAt: string | null;
replacedByUserId: string | null;
replaceReason: string | null;
}
async function postContract<T>(url: string, body?: unknown): Promise<T> {
const response = await client.post<T>(url, body ?? {});
return unwrap(response.data);
@@ -297,6 +314,50 @@ export const contractsService = {
) =>
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postContract<Freight.IContract>(
C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id),
{ note },
),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
}),
/**
* Replace a clearance document in place. The customer's original is retired
* into the version history rather than overwritten, and the new file comes
* back unreviewed so it still has to be approved.
*/
replaceClearanceDocument: async (
id: string,
fileKey: string,
file: File,
reason: string,
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
form.append("reason", reason);
const response = await client.post(
C.CLEARANCE_DOC_REPLACE(id, fileKey),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.IContract;
},
/** Every stored version of one clearance document, newest first. */
getClearanceDocumentVersions: async (
id: string,
fileKey: string,
): Promise<ClearanceDocumentVersion[]> => {
const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey));
return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? [];
},
uploadClearanceOutput: async (
id: string,
files: Record<string, File | null>,
@@ -390,11 +451,12 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});

View File

@@ -153,6 +153,9 @@ export const URL_CONSTANTS = {
CLEARANCE_DOCUMENTS: (id: string) =>
`/api/contracts/${id}/clearance/documents`,
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
/** Ask GL Ethiopia to correct the advised duty instead of paying it. */
CLEARANCE_DUTY_DISPUTE: (id: string) =>
`/api/contracts/${id}/clearance/duty/dispute`,
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`,
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
import { Alert, Box, Button, Group, Paper, Stack, Text, Textarea } from "@mantine/core";
import { AlertTriangle, ArrowRight, Download, MessageSquareWarning, PackageCheck, Receipt, Upload } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -68,7 +68,26 @@ export function ContractClearanceWorkflowBanner({
</Alert>
) : null}
{dutyPending && clearance.dutyAdvice ? (
{/* Duty is disputed: GL owes a corrected advice, so the pay/upload
panel is replaced by the waiting state until they re-send it. */}
{clearance.dutyDispute ? (
<Alert
color="orange"
variant="light"
icon={<MessageSquareWarning size={16} />}
title="Waiting for a corrected duty amount"
>
<Stack gap={4}>
<Text fz={13}>
You asked GL Ethiopia to review the advised duty & tax. They
will send a corrected notice you will be notified.
</Text>
<Text fz={12} c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
Your message: {clearance.dutyDispute.note}
</Text>
</Stack>
</Alert>
) : dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
contractId={contract.id}
@@ -139,6 +158,11 @@ function DutyAdvicePanel({
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
// Accept → pay and upload the slip. Query → say what is wrong and send it
// back to GL Ethiopia for a corrected notice.
const [disputing, setDisputing] = useState(false);
const [disputeNote, setDisputeNote] = useState("");
const [sendingDispute, setSendingDispute] = useState(false);
return (
<Paper
@@ -216,8 +240,62 @@ function DutyAdvicePanel({
) : null}
</Paper>
{disputing ? (
<Stack gap="sm">
<Text fz={13} c="dimmed">
Pay the amount above, then upload your payment slip so clearance can continue.
Tell GL Ethiopia what is wrong with this amount. They will review
and send a corrected notice.
</Text>
<Textarea
label="What needs correcting?"
placeholder="e.g. the declared value is wrong — the invoice total is 412,000 ETB"
value={disputeNote}
onChange={(e) => setDisputeNote(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group gap="sm" grow>
<Button
variant="default"
onClick={() => {
setDisputing(false);
setDisputeNote("");
}}
>
Back
</Button>
<Button
color="orange"
loading={sendingDispute}
disabled={!disputeNote.trim()}
onClick={async () => {
setSendingDispute(true);
try {
await contractsService.disputeContractDuty(
contractId,
disputeNote.trim(),
);
toast.success("Sent back to GL Ethiopia for correction");
setDisputing(false);
setDisputeNote("");
onUploaded();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Could not send");
} finally {
setSendingDispute(false);
}
}}
>
Send for correction
</Button>
</Group>
</Stack>
) : (
<>
<Text fz={13} c="dimmed">
Pay the amount above, then upload your payment slip so clearance can
continue. If the amount looks wrong, ask GL Ethiopia to correct it
before paying.
</Text>
<PortalFileDropzone
@@ -249,8 +327,20 @@ function DutyAdvicePanel({
}
}}
>
Submit payment slip
Accept & submit payment slip
</Button>
<Button
variant="subtle"
color="orange"
leftSection={<MessageSquareWarning size={15} />}
fullWidth
onClick={() => setDisputing(true)}
>
Request a change to this amount
</Button>
</>
)}
</Stack>
</Paper>
);

View File

@@ -610,7 +610,6 @@ export default function NewContractPage({
? Freight.ContractFreightType.Container
: Freight.ContractFreightType.Bulk,
serviceTypeId: data.serviceTypeId,
paymentCurrency: data.paymentCurrency,
// Empty-container return is a contract-level opt-in (container freight
// only) — like hazardous. Per-booking return quantities are still set at
// booking time, but only on contracts created WITH_RETURN.

View File

@@ -21,6 +21,7 @@ import {
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Switch,
Text,
@@ -262,6 +263,10 @@ function NewShipmentBookingForm({
// Seed the equipment-return toggle from the contract; the customer can
// still flip it per shipment.
withReturn: contract.equipmentReturn === "WITH_RETURN",
// The contract quotes USD; the customer bills this shipment in the
// currency they pick here. Intercity is always ETB.
paymentCurrency:
contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
},
resolver: zodResolver(
createShipmentFormSchema({
@@ -332,6 +337,7 @@ function NewShipmentBookingForm({
...(values.contractRouteId
? { contractRouteId: values.contractRouteId }
: {}),
paymentCurrency: values.paymentCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
@@ -1003,6 +1009,12 @@ function ScheduleStep({
corridor. Operations assign it to a train with free capacity you
will be notified when it is accepted and payment is due.
</Alert>
<Box mt="md">
<StepLabel>Billing currency</StepLabel>
<Text fz={13} c="dimmed" mt={6}>
Intercity shipments are invoiced in <strong>ETB</strong>.
</Text>
</Box>
</StepCard>
);
}
@@ -1014,6 +1026,29 @@ function ScheduleStep({
title="Schedule"
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
/>
<Controller
name="paymentCurrency"
control={form.control}
render={({ field }) => (
<Box mb="lg">
<StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
Your contract is quoted in USD. Pick the currency this shipment is
invoiced in the total is converted for you.
</Text>
<SegmentedControl
value={field.value ?? "USD"}
onChange={(v) => field.onChange(v)}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/>
</Box>
)}
/>
{cargoQuery === null ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Enter your cargo details first available shipment days depend on the

View File

@@ -9,6 +9,7 @@ import {
Loader,
NumberInput,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
@@ -33,6 +34,9 @@ export default function NewShipmentRequestPage() {
);
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
const [bulkAmount, setBulkAmount] = useState<number | string>("");
// GL books this shipment on the customer's behalf, so the currency they want
// to be invoiced in has to be stated here — the contract itself quotes USD.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -77,11 +81,12 @@ export default function NewShipmentRequestPage() {
const isContainer = contract.freightType === "CONTAINER";
const route = contract.routes?.[0];
// GENERAL customs contracts: GL schedules the shipment during clearance —
// the customer only states the quantity, never picks a date.
// Customs contracts: GL schedules the shipment during clearance — the
// customer only states the quantity (and currency), never picks a date. This
// now covers ONE_TIME customs too, where GL likewise books on their behalf.
const hasCustoms =
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled;
const isIntercity = contract.tradeDirection === "DOMESTIC";
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
@@ -110,6 +115,7 @@ export default function NewShipmentRequestPage() {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
notes: notes.trim() || undefined,
};
@@ -233,6 +239,28 @@ export default function NewShipmentRequestPage() {
</Text>
) : null}
<Box>
<Text size="sm" fw={600} mb={4}>
Billing currency *
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="teal"
radius={10}
/>
</Box>
<Textarea
label="Notes (optional)"
value={notes}

View File

@@ -15,6 +15,18 @@ export const TERMINAL_BOOKING_STATUSES = [
/** Path A statuses where a customer (no customs) may book against the contract. */
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
/**
* ONE_TIME + customs: statuses where the customer may still submit the shipment
* request GL books from. Mirrors the API source of truth in
* `booking-request.service.ts`.
*/
const ONE_TIME_CUSTOMS_REQUESTABLE = [
"FULLY_EXECUTED",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
/** Split bookings in these statuses released their quantity — no remainder to book. */
const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"];
@@ -49,20 +61,31 @@ export function getContractBookingAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): ContractBookingAction {
// GENERAL + customs: customer submits a shipment request; GL creates the booking.
if (
contract.customsClearingEnabled &&
contract.contractKind === "GENERAL" &&
contract.status === "CONTRACT_ACTIVE"
) {
// Customs: the customer never books directly — GL does it for them. The
// shipment request is how they say what to ship and which currency to be
// invoiced in (the contract itself quotes USD only).
if (contract.customsClearingEnabled) {
const requestable =
contract.contractKind === "GENERAL"
? contract.status === "CONTRACT_ACTIVE"
: // ONE_TIME: both signatures in, GL has not booked yet. Mirrors
// BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.
ONE_TIME_CUSTOMS_REQUESTABLE.includes(contract.status);
// One shipment, one open request — the API rejects a second one, so don't
// offer the button while a request is still pending.
const hasOpenRequest =
contract.contractKind === "ONE_TIME" &&
bookings.some((b) => b.contractId === contract.id);
if (requestable && !hasOpenRequest) {
return {
kind: "request",
to: `/contracts/${contract.id}/shipment-requests/new`,
};
}
// Other customs (ONE_TIME): booked by GL — no customer action.
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
return { kind: "none", to: "" };
}
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
const to = `/contracts/${contract.id}/bookings/new`;

View File

@@ -91,8 +91,6 @@ export function contractToFormValues(
previousContractRef: contract.renewalOfId ?? "",
serviceTypeId: contract.serviceTypeId,
paymentCurrency:
contract.paymentCurrency === "ETB" ? "ETB" : "USD",
firstMile: {
enabled: hasFirstMile,

View File

@@ -1,56 +0,0 @@
import { Select, Text } from "@mantine/core";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
type ContractFormInputValues,
type ContractFormValues,
} from "./schema";
import { fieldStyles } from "./shared";
export function PaymentCurrencyField({
control,
etbOnly = false,
}: {
control: Control<ContractFormInputValues, any, ContractFormValues>;
/** Intercity (domestic) contracts are priced in ETB only. */
etbOnly?: boolean;
}) {
const options = etbOnly
? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
: PAYMENT_CURRENCY_OPTIONS;
return (
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => {
const selected = options.find((o) => o.value === field.value);
return (
<div>
<Select
label="Payment Currency *"
placeholder="Select currency…"
data={options.map((o) => ({
value: o.value,
label: o.label,
}))}
value={field.value || null}
onChange={(v) => v && field.onChange(v)}
onBlur={field.onBlur}
error={fieldState.error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
{selected && (
<Text fz={12} c="#6B7C8E" mt={6}>
{selected.description}
</Text>
)}
</div>
);
}}
/>
);
}

View File

@@ -125,7 +125,6 @@ export const contractFormSchema = z
serviceTypeId: z
.string("Select a service type.")
.min(1, "Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
@@ -220,14 +219,6 @@ export const contractFormSchema = z
},
)
.superRefine((data, ctx) => {
// Intercity (domestic) contracts are priced and invoiced in ETB only.
if (data.operationType === "intercity" && data.paymentCurrency !== "ETB") {
ctx.addIssue({
code: "custom",
path: ["paymentCurrency"],
message: "Intercity contracts are priced in ETB.",
});
}
// Container scope needs no validation: both sizes are always in scope and
// the cargo description moved to booking time.
if (data.cargoType === "bulk") {
@@ -279,8 +270,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
previousContractRef: "",
serviceTypeId: "",
// No preselected currency — the customer must choose (intercity forces ETB).
paymentCurrency: undefined,
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "without_return",
@@ -316,7 +305,6 @@ export const contractStepFields: Record<
"contractType",
"previousContractRef",
"serviceTypeId",
"paymentCurrency",
"equipmentReturn",
"customsClearingEnabled",
"customsClearingAgent",

View File

@@ -104,12 +104,6 @@ export function Step1ContractType({
// ── Service type ──
const serviceId = contract.serviceTypeId;
if (serviceId) form.setValue("serviceTypeId", serviceId);
if (contract.paymentCurrency) {
form.setValue(
"paymentCurrency",
contract.paymentCurrency as ContractFormValues["paymentCurrency"],
);
}
// ── First / last mile ──
form.setValue("firstMile", {

View File

@@ -16,7 +16,6 @@ import { Controller, type UseFormReturn } from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { filterBookableServices, operationToTradeDirection } from "./helpers";
import { fieldStyles, StepLabel } from "./shared";
import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "@/pages/bookings/new-booking-form/LocationPicker";
import type { Freight } from "@edr/types";
@@ -347,14 +346,11 @@ export function Step2ServiceType({
}
}, [operationType, standaloneServices, form]);
// Intercity (domestic) contracts are priced in ETB only — force the currency
// and let the field render just the ETB option. Also clears a stale USD from
// a restored draft or an operation-type switch.
// A contract is always quoted in USD now — the billing currency is chosen per
// shipment (at booking, or on the shipment request when GL books). Intercity
// still bills in ETB, but that is applied at booking time, not here.
const isIntercity = operationType === "intercity";
useEffect(() => {
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
}
// The customs clearing agent field is hidden for intercity — drop any value
// carried over from a draft or an operation-type switch.
if (isIntercity && form.getValues("customsClearingAgent")) {
@@ -378,10 +374,6 @@ export function Step2ServiceType({
)}
/>
<Box maw={420}>
<PaymentCurrencyField control={form.control} etbOnly={isIntercity} />
</Box>
{showServiceSections && (
<Stack gap={12}>
<StepLabel>Trucking & customs options</StepLabel>

View File

@@ -353,8 +353,15 @@ export function Step8Review({
/>
<SummaryItem
icon={<Coins size={18} />}
label="Payment currency"
value={values.paymentCurrency ?? "USD"}
label="Quotation currency"
value={
<>
USD
<Text fz="sm" c="dimmed" mt={4}>
You choose the billing currency on each shipment.
</Text>
</>
}
/>
<SummaryItem
icon={<Route size={18} />}
@@ -500,10 +507,6 @@ export function Step8Review({
done={Boolean(values.serviceTypeId)}
label="Service configured"
/>
<ReadinessItem
done={Boolean(values.paymentCurrency)}
label="Payment currency selected"
/>
<ReadinessItem
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"

View File

@@ -73,6 +73,9 @@ const containerLineSchema = z.object({
const shipmentFormBase = z.object({
contractRouteId: z.string().default(""),
scheduledDate: z.string().default(""),
// The contract quotes in USD; the customer picks the billing currency for
// THIS shipment. Intercity is forced to ETB (server-enforced too).
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
// Container contracts only: return the empty container(s) to EDR after
// unloading. Seeded from the contract's equipment return; bulk ignores it.
withReturn: z.boolean().default(false),

View File

@@ -305,6 +305,18 @@ export const contractsService = {
return data.data ?? data;
},
/**
* Reject the advised duty & tax with a reason. The clearance step goes back
* to GL Ethiopia, who re-advises a corrected amount; this can repeat.
*/
disputeContractDuty: async (
id: string,
note: string,
): Promise<Freight.IContract> => {
const { data } = await client.post(C.CLEARANCE_DUTY_DISPUTE(id), { note });
return data.data ?? data;
},
uploadContractDutySlip: async (
id: string,
file: File,

View File

@@ -41,7 +41,108 @@ export default defineConfig({
process.env.E2E_DB_URL ??
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
// Shapes already retired in THIS cypress run. The Node plugin process
// outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and
// so `before()` — on every cross-origin visit), so a run-scoped set here
// is what keeps the cleanup from firing a second time and cancelling the
// contract the spec had just created. Browser-side state cannot do this:
// Cypress.env() is reset by the reload.
const retiredContractShapes = new Set<string>();
/** Keys already executed by `db:queryOnce` in this cypress run. */
const onceRunKeys = new Set<string>();
// Stamped when the plugin loads, i.e. once per cypress run. Cleanup only
// ever touches rows older than this, so nothing the current run creates
// can be cancelled out from under it.
const runStartedAt = new Date().toISOString();
on("task", {
/**
* Cancel a previous run's contracts of one shape so a spec can run
* again against a warm DB (the API allows one active contract per
* customer + service type + route). Runs at most once per shape per
* cypress run — see `retiredContractShapes`.
*/
async "db:retireStaleContracts"({
tin,
kind,
direction,
}: {
tin: string;
kind: string;
direction: string;
}) {
const key = `${tin}:${kind}:${direction}`;
if (retiredContractShapes.has(key)) return { skipped: true };
retiredContractShapes.add(key);
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const result = await client.query(
`UPDATE freight.contracts ct
SET status = 'CANCELLED'
FROM freight.companies c
WHERE c.id = ct.company_id
AND c.tin = $1
AND ct.deleted_at IS NULL
AND ct.contract_kind = $2
AND ct.trade_direction = $3
-- Only ever previous runs' rows.
AND ct.created_at < $4::timestamptz
-- Corridor fixtures are re-seeded by reference, so a
-- cancelled one would never come back — leave them alone.
AND ct.reference NOT LIKE 'CTR-IMP-%'
-- Segment fixtures are stamped per run and always booked by
-- their own spec. An UNBOOKED leftover is debris that still
-- holds the lane (one active contract per service + route),
-- which blocked the intercity spec from filing its own.
AND (
ct.reference NOT LIKE 'CTR-SEG-%'
OR NOT EXISTS (
SELECT 1 FROM freight.bookings b
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
)
)
AND ct.status NOT IN
('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`,
[tin, kind, direction, runStartedAt],
);
return { skipped: false, cancelled: result.rowCount };
} finally {
await client.end();
}
},
/**
* Run a statement at most ONCE per cypress run, keyed by `key`.
*
* For arrange-data that must not repeat: Cypress re-evaluates the spec
* bundle on every cross-origin visit, so a `before()` hook fires again
* mid-spec and a plain cleanup would then wipe what the run had just
* created. The Node plugin process outlives those reloads, so the guard
* lives here rather than in the browser.
*/
async "db:queryOnce"({
key,
sql,
params = [],
}: {
key: string;
sql: string;
params?: unknown[];
}) {
if (onceRunKeys.has(key)) return { skipped: true };
onceRunKeys.add(key);
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const result = await client.query(sql, params as never[]);
return { skipped: false, rowCount: result.rowCount };
} finally {
await client.end();
}
},
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
const client = new Client({ connectionString: dbUrl });

View File

@@ -50,6 +50,20 @@ function expectStatus(expected: string) {
}
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
before(() => {
// Re-runnable against a warm DB. The API allows one active contract per
// customer + service type + route, so this spec's own contract from an
// earlier run 409s the new one at creation — and since staff accept it
// with a year's validity, it would keep doing so for a year. Retire just
// the shape this spec creates (its own wizard-made GENERAL imports),
// leaving the seeded corridor/segment fixtures alone.
cy.retireStaleContracts({
tin: companyTin,
kind: "GENERAL",
direction: "IMPORT",
});
});
it("customer creates and submits a GENERAL import container contract", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");

View File

@@ -117,9 +117,16 @@ function dbUpcomingSchedule() {
return cy.task<{
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
}>("db:query", {
// Scoped to THIS journey's corridor (… → Djibouti Port). Direction + a 25h
// window alone also matches the segment-weight spec's trains, which run
// Mojo → Nagad on the same day — and one of those is deliberately driven
// FULL, so the unscoped query handed this spec a full schedule whose
// booking window never opens.
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
FROM freight.train_schedules ts
JOIN freight.yards d ON d.id = ts.destination_station_id
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
AND d.code = 'DJIB_PORT'
AND ts.scheduled_departure_date > now()
AND ts.scheduled_departure_date < now() + interval '25 hours'
ORDER BY ts.created_at DESC LIMIT 1`,
@@ -137,14 +144,17 @@ function fill(label: string | RegExp, value: string) {
});
}
/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
function fillNth(label: RegExp, index: number, value: string) {
cy.get("label").then(($labels) => {
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
const id = matches.eq(index).attr("for");
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
/**
* Set one container-size line's quantity, found by its "20ft containers" /
* "40ft containers" heading rather than by position in the list.
*/
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.contains(`${size} containers`, { timeout: 15000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
}
/**
@@ -315,6 +325,51 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
before(() => {
cy.task("db:seedFile", "seed-intercity.sql");
cy.task("db:seedFile", "seed-export.sql");
// Re-runnable against a warm DB: one active contract per customer +
// service type + route, so an earlier run's ONE_TIME export contract
// 409s this run at creation. A SPENT one (already booked) no longer
// blocks, but a run that died before booking leaves a live one behind.
cy.retireStaleContracts({
tin: companyTin,
kind: "ONE_TIME",
direction: "EXPORT",
});
// The schedule step reuses an upcoming export schedule when one exists, but
// a previous run's train is already loaded — and an export booking must
// ride a single train WHOLE, so this run's booking then fails the space
// check with "no open train on this day can carry it". Retire the older
// schedules (unlinking their bookings) so this run gets an empty train.
// Once per run: before() fires again on cross-origin visits, and a second
// pass would delete the schedule this run had just created.
cy.task("db:queryOnce", {
key: "export_one_time:reset-upcoming-export-schedule",
sql: `WITH stale AS (
SELECT ts.id
FROM freight.train_schedules ts
JOIN freight.yards d
ON d.id = ts.destination_station_id AND d.code = 'DJIB_PORT'
WHERE ts.direction = 'EXPORT'
AND ts.deleted_at IS NULL
AND ts.scheduled_departure_date > now()
AND ts.scheduled_departure_date < now() + interval '25 hours'
), unlink AS (
UPDATE freight.bookings b
SET train_schedule_id = NULL,
scheduling_status = 'NOT_SCHEDULED',
status = CASE
WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
THEN 'EXPIRED' ELSE b.status END
WHERE b.train_schedule_id IN (SELECT id FROM stale)
), drop_links AS (
UPDATE freight.train_schedule_bookings
SET deleted_at = now()
WHERE train_schedule_id IN (SELECT id FROM stale)
AND deleted_at IS NULL
)
UPDATE freight.train_schedules
SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`,
});
});
// ── Shared infrastructure ─────────────────────────────────────────────────
@@ -457,9 +512,13 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
// Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
fillNth(/^Quantity/, 0, "2");
fillNth(/^Quantity/, 1, "1");
// Address each size editor by its heading, not by position: the two lines
// come out of the contract's cargo scope and are not guaranteed to be in
// 20ft-then-40ft order. Reversed, this booked 1 × 20ft — an odd count,
// which the form blocks (a lone 20ft can never be paired onto a wagon), so
// "Review price & book" stayed disabled.
fillSizeQuantity("20ft", "2");
fillSizeQuantity("40ft", "1");
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));

View File

@@ -67,6 +67,23 @@ function requestByReason(reason: string) {
describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
// This spec asserts the "no available wagons" guard against E2E_AWASH, so
// that yard must actually hold none. Run alone it does, but in a full-suite
// run an earlier corridor spec can leave idle wagons standing there and the
// guard then returns 201 instead of 400. Park them back at KALITY — only
// loose AVAILABLE wagons move, so nothing another spec is using is touched.
cy.task("db:query", {
sql: `UPDATE freight.wagons w
SET current_yard_id = k.id
FROM freight.yards a, freight.yards k
WHERE a.code = 'E2E_AWASH'
AND k.code = 'KALITY'
AND w.current_yard_id = a.id
AND w.train_id IS NULL
AND w.current_train_schedule_id IS NULL
AND w.status = 'AVAILABLE'
AND w.deleted_at IS NULL`,
});
});
it("files a count-only request — same-yard and empty-source are rejected", () => {

View File

@@ -136,13 +136,13 @@ function dbSchedule() {
* Fill the N-th labelled Mantine input (label[for] → input id). Indexed
* because the booking form renders one "Quantity *" per container size.
*/
function fillNth(label: RegExp, index: number, value: string) {
cy.get("label").then(($labels) => {
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
const id = matches.eq(index).attr("for");
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.contains(`${size} containers`, { timeout: 15000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
}
/**
@@ -188,6 +188,14 @@ describe("intercity one-time journey: contract → booking → export train", {
// Container types, locomotives, built train, yard distances — the
// infrastructure the UI journey cannot create in-flow.
cy.task("db:seedFile", "seed-intercity.sql");
// Re-runnable against a warm DB: an earlier run's DOMESTIC ONE_TIME
// contract on this lane would 409 this run's at creation (a spent one no
// longer blocks, but an unbooked leftover does).
cy.retireStaleContracts({
tin: companyTin,
kind: "ONE_TIME",
direction: "DOMESTIC",
});
});
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
@@ -390,8 +398,8 @@ describe("intercity one-time journey: contract → booking → export train", {
// 40ft line, each seeded quantity 1. Book 2 × 20ft (pairs share a wagon,
// so the count must be even) and zero the 40ft line — otherwise its
// default unit adds a third container-number input.
fillNth(/^Quantity/, 0, "2");
fillNth(/^Quantity/, 1, "0");
fillSizeQuantity("20ft", "2");
fillSizeQuantity("40ft", "0");
// One ISO container number per unit.
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);

View File

@@ -199,6 +199,26 @@ Cypress.Commands.add("fillCargoDescription", (text = "Electronics") => {
});
});
/**
* Retire a previous run's contracts so a spec is re-runnable against a warm DB.
*
* The API allows one active contract per customer + service type + route, so
* yesterday's contract 409s today's at creation — and staff accept contracts
* with a year's validity, so it would keep doing so for a year.
*
* The cutoff is frozen on first use: Cypress re-evaluates the spec bundle on
* cross-origin visits, so `before()` fires again mid-run, and a naive "cancel
* this shape" would then cancel the contract THIS run had just created.
* Anything created after the spec started is ours and must survive.
*/
Cypress.Commands.add(
"retireStaleContracts",
(opts: { tin: string; kind: string; direction: string }) => {
// The once-per-run guard lives in the Node task (see cypress.config.ts).
cy.task("db:retireStaleContracts", opts);
},
);
/** Type a 6-digit code into a Mantine PinInput. */
Cypress.Commands.add("typeOtp", (code: string) => {
cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length);
@@ -257,6 +277,12 @@ declare global {
uploadCompanyStamp(): Chainable<void>;
/** Fill the required per-booking cargo description (container only). */
fillCargoDescription(text?: string): Chainable<void>;
/** Cancel a previous run's contracts of this shape (warm-DB re-runs). */
retireStaleContracts(opts: {
tin: string;
kind: string;
direction: string;
}): Chainable<void>;
/** Fill a Mantine PinInput with a code. */
typeOtp(code: string): Chainable<void>;
/** Scribble on the signature-pad canvas inside the open modal. */

View File

@@ -285,13 +285,23 @@ export type IContractDocumentChange =
toOrder: number;
}
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
/** A contract field (not a document article) changed on a customer edit. */
| {
kind: 'FIELD_CHANGED';
field: string;
label: string;
from: string | null;
to: string | null;
};
/** An audit entry for one edit to a contract's document. */
export interface IContractDocumentRevision {
id: string;
contractId: string;
actorId: string | null;
/** Who made the edit, captured at the time (survives renames/deletions). */
actorName?: string | null;
/** The approval step's required role at the time of the edit. */
actorRole: string | null;
stepId: string | null;
@@ -451,6 +461,9 @@ export interface ContractClearanceView {
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
/** Import DO: vessel arrival + DO collection dates, recorded by GL Djibouti. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
roAmendmentRequestedAt?: string | null;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
@@ -464,12 +477,32 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null;
/** Shipment day the booking holds; the default when GL resubmits it. */
linkedBookingScheduledDate?: string | null;
/**
* Pre-declaration handshake with GL Djibouti: who handles the shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty. Present only until GL
* re-advises; `rounds` counts how many times it has been sent back.
*/
dutyDispute?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
/** Import post-allocation T1 transit document state (null until a booking is linked). */
@@ -887,6 +920,8 @@ export interface CreateBulkLineDto {
export interface CreateBookingUnderContractDto {
/** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */
contractRouteId?: string;
/** Billing currency for this shipment. Intercity is always ETB. */
paymentCurrency?: string;
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
scheduledDate?: string;
/** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */
@@ -936,6 +971,8 @@ export interface IBookingRequest extends BaseEntity {
scheduledDate?: string | null;
status: BookingRequestStatus;
requestedLines: RequestedShipmentLines;
/** Billing currency the customer chose; GL books the shipment in it. */
paymentCurrency?: string | null;
notes?: string | null;
/** Set when GL accepts and creates the booking. */
createdBookingId?: string | null;
@@ -992,6 +1029,8 @@ export interface CreateBookingRequestDto {
itemCount?: number;
hazardousQuantity?: number;
};
/** Billing currency for the shipment GL will book. Intercity is always ETB. */
paymentCurrency?: string;
notes?: string;
}

View File

@@ -774,6 +774,9 @@ export interface ClearanceView {
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
/** Import DO: vessel arrival + DO collection dates, recorded by GL Djibouti. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
roAmendmentRequestedAt?: string | null;
/** Boundary milestone complete — customer may proceed to operations. */
operationReady?: boolean;