mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -32,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -175,6 +176,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
ConsignmentsModule,
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
TransitAgentsModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
|
||||
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
|
||||
* a free-form upload has and a catalog-driven one does not: the uploader's own
|
||||
* title, who uploaded it (the only user allowed to change it afterwards) and
|
||||
* whether the customer may see it in the portal.
|
||||
*/
|
||||
export class AddGlExchangeDocumentFields3030000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddGlExchangeDocumentFields3030000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.files
|
||||
ADD COLUMN IF NOT EXISTS title varchar(300),
|
||||
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
|
||||
);
|
||||
// Every read of a thread is "all files of one resource" — index the pair.
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
|
||||
ON freight.files (resource, resource_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.files
|
||||
DROP COLUMN IF EXISTS title,
|
||||
DROP COLUMN IF EXISTS visible_to_customer,
|
||||
DROP COLUMN IF EXISTS uploaded_by_user_id,
|
||||
DROP COLUMN IF EXISTS uploaded_by_name;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddTransitAgents3040000000000 implements MigrationInterface {
|
||||
name = "AddTransitAgents3040000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.transit_agents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name varchar(150) NOT NULL,
|
||||
valid_from date NOT NULL,
|
||||
valid_to date NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active
|
||||
ON freight.transit_agents (is_active)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
|
||||
name = "AddCbeBillPaymentMethod3050000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
|
||||
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
}
|
||||
}
|
||||
@@ -995,6 +995,7 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
@@ -1023,6 +1024,10 @@ export class BillingService {
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
||||
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
||||
payerName: invoice.company?.name,
|
||||
expiresAt: invoice.dueAt?.toISOString(),
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
@@ -1033,8 +1038,9 @@ export class BillingService {
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
// billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL —
|
||||
// its bill must stay open until CBE actually settles it via /cbe/payment.
|
||||
if (!result.immediateSuccess && opts.method !== "CBE_BILL") {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
@@ -1079,4 +1085,52 @@ export class BillingService {
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
||||
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
||||
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
||||
*/
|
||||
async billQuery(referenceId: string): Promise<{
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
reason?: string | null;
|
||||
}> {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const open = await repo.findOne({
|
||||
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
payerName: open.company?.name ?? null,
|
||||
currentAmountMinor: balance,
|
||||
currency: open.currency,
|
||||
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
|
||||
};
|
||||
}
|
||||
|
||||
const latest = await repo.findOne({
|
||||
where: { sourceId: referenceId },
|
||||
relations: { company: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest?.company?.name ?? null,
|
||||
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
|
||||
currency: latest?.currency ?? null,
|
||||
reason:
|
||||
latest?.status === Freight.InvoiceStatus.Paid
|
||||
? "ALREADY_PAID"
|
||||
: "CANCELLED",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,11 +317,11 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
|
||||
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
|
||||
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
|
||||
`Please review and approve it in the portal, then pay and upload the payment slip.`;
|
||||
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
||||
this.inApp(b, 'Final invoice issued', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
@@ -361,6 +361,15 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
|
||||
finalInvoiceApprovedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Final invoice approved',
|
||||
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the booking contract. */
|
||||
customerSignedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
@@ -392,16 +401,27 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(b: Booking, note: string): void {
|
||||
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
|
||||
draftDeclarationReady(b: Booking, price: number, currency: string): void {
|
||||
const msg =
|
||||
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, {
|
||||
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
|
||||
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
|
||||
this.inApp(b, 'Draft declaration ready for review', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer asked for a change on the draft declaration. This goes to
|
||||
* STAFF, not the customer: GL Ethiopia is the one who has to send a
|
||||
* corrected draft, and the clearance page is where they do it.
|
||||
*/
|
||||
draftDeclarationChangeRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Send a corrected draft from the clearance page.`;
|
||||
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
|
||||
@@ -841,13 +841,13 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
async assignBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -900,17 +900,52 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty/dispute')
|
||||
@Post(':id/clearance/draft-declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
|
||||
})
|
||||
async disputeBookingDuty(
|
||||
async uploadBookingDraftDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('price') priceRaw: string,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
Number(priceRaw),
|
||||
currency ?? 'ETB',
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/accept')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||
})
|
||||
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/change')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
|
||||
})
|
||||
async requestBookingDraftDeclarationChange(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.disputeDuty(
|
||||
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
|
||||
@@ -7,10 +7,10 @@ export const REVIEW_NOTE_TYPES = [
|
||||
'REJECTION',
|
||||
'STAFF_NOTE',
|
||||
/**
|
||||
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||
* The customer asked GL Ethiopia to correct the draft customs declaration
|
||||
* (price/files). One row per round — the draft/change-request loop can repeat.
|
||||
*/
|
||||
'DUTY_DISPUTE',
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ function makeService(overrides?: {
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
|
||||
{
|
||||
getAssignable: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDraftDeclarationFileCode,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
type GlExchangeDocument,
|
||||
} from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -89,10 +94,22 @@ export interface BookingClearanceView {
|
||||
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.
|
||||
* Import only: the draft customs declaration GL Ethiopia sends before filing
|
||||
* the real one. Present once a draft has been uploaded, regardless of
|
||||
* accept state — `accepted` tells the caller which.
|
||||
*/
|
||||
dutyDispute?: {
|
||||
draftDeclaration?: {
|
||||
price: number;
|
||||
currency: string;
|
||||
files: Array<{ id: string; name: string; url: string }>;
|
||||
accepted: boolean;
|
||||
} | null;
|
||||
/**
|
||||
* The customer's open change request on the current draft declaration.
|
||||
* Present only until GL sends a corrected draft; `rounds` counts how many
|
||||
* times it has been sent back.
|
||||
*/
|
||||
draftDeclarationChangeRequest?: {
|
||||
note: string;
|
||||
raisedAt: string;
|
||||
rounds: number;
|
||||
@@ -107,6 +124,8 @@ export interface BookingClearanceView {
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** Offload stats for this booking (what came off the train, and where). */
|
||||
offload?: ClearanceOffloadState | null;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
@@ -117,6 +136,8 @@ export interface BookingClearanceView {
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
/** GL-shared documents this booking's uploader marked visible to the customer. */
|
||||
exchangeDocuments?: GlExchangeDocument[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -131,6 +152,8 @@ export class BookingClearanceService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -231,7 +254,11 @@ export class BookingClearanceService {
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
|
||||
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
|
||||
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
|
||||
bookingId,
|
||||
milestones,
|
||||
);
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
@@ -259,6 +286,12 @@ export class BookingClearanceService {
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||
// GL↔GL exchange documents shared with the customer. The two desks may work
|
||||
// the thread on the booking (per-booking customs) or on its contract
|
||||
// (pre-booking clearance), so the customer's view spans both.
|
||||
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
|
||||
[bookingId, booking.contractId ?? ''],
|
||||
);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
@@ -301,8 +334,10 @@ export class BookingClearanceService {
|
||||
: null,
|
||||
},
|
||||
dutyAdvice,
|
||||
dutyDispute,
|
||||
draftDeclaration,
|
||||
draftDeclarationChangeRequest,
|
||||
workflowFiles,
|
||||
exchangeDocuments,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepass.granted,
|
||||
@@ -313,6 +348,7 @@ export class BookingClearanceService {
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
offload: await this.glOperationsService.offloadState(bookingId, milestones),
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
@@ -359,13 +395,37 @@ export class BookingClearanceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDutyDispute(
|
||||
private buildDraftDeclaration(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): BookingClearanceView['draftDeclaration'] {
|
||||
const uploaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!uploaded?.metadata) return null;
|
||||
const price = uploaded.metadata.draftDeclarationPrice;
|
||||
const currency = uploaded.metadata.draftDeclarationCurrency;
|
||||
if (typeof price !== 'number' || typeof currency !== 'string') return null;
|
||||
const draftFiles = files
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
|
||||
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
|
||||
const accepted =
|
||||
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
|
||||
'COMPLETED';
|
||||
return { price, currency, files: draftFiles, accepted };
|
||||
}
|
||||
|
||||
private async buildDraftDeclarationChangeRequest(
|
||||
bookingId: string,
|
||||
milestones: ClearanceMilestone[],
|
||||
): Promise<BookingClearanceView['dutyDispute']> {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||
if (!advised || advised.status === 'COMPLETED') return null;
|
||||
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
|
||||
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
|
||||
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||
if (!uploaded || uploaded.status === 'COMPLETED') return null;
|
||||
const notes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
);
|
||||
const latest = notes[0];
|
||||
if (!latest) return null;
|
||||
return {
|
||||
@@ -426,28 +486,27 @@ export class BookingClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* GL Djibouti picks the transit officer from the admin-managed roster —
|
||||
* rejected unless the agent is active and inside its validity window.
|
||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||
* the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
|
||||
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
if (!booking.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
|
||||
|
||||
const previous = booking.transitAssigneeName ?? null;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
|
||||
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -499,12 +558,6 @@ export class BookingClearanceService {
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
} as never);
|
||||
|
||||
// Export: the declaration is the last GL ET pre-operation action — release
|
||||
// immediately so the customer can proceed without a separate confirm click.
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -562,61 +615,120 @@ export class BookingClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disagrees with the advised duty & tax on this booking 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.
|
||||
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
|
||||
* the customer to review before the real declaration is filed. Repeatable —
|
||||
* each call replaces the previous draft's files/price and re-arms the step,
|
||||
* which is what a re-send after a change request needs.
|
||||
*/
|
||||
async disputeDuty(
|
||||
async uploadDraftDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
price: number,
|
||||
currency: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No draft declaration documents uploaded');
|
||||
}
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
throw new BadRequestException('A valid estimated price is required.');
|
||||
}
|
||||
// Backfills the two new milestone rows for bookings seeded before this step
|
||||
// existed — a blind complete() 404s on a booking with no such row yet.
|
||||
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'DRAFT_DECLARATION_UPLOADED',
|
||||
);
|
||||
|
||||
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'DRAFT_DECLARATION_UPLOADED',
|
||||
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
|
||||
userId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.draftDeclarationReady(updated, price, currency);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||
* real customs declaration.
|
||||
*/
|
||||
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||
if (uploaded?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('There is no draft declaration to accept yet.');
|
||||
}
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer sends the draft declaration back with a reason. Nothing is
|
||||
* filed; the upload milestone reopens so the step becomes actionable again
|
||||
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
|
||||
* (same endpoint as the first time), which closes the request — the loop may
|
||||
* run as many rounds as it takes.
|
||||
*/
|
||||
async requestDraftDeclarationChange(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty applies only to import bookings.');
|
||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'Say what is wrong with the advised amount so GL can correct it.',
|
||||
'Say what needs to change so GL can correct the draft.',
|
||||
);
|
||||
}
|
||||
if (!booking.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
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.',
|
||||
);
|
||||
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('There is no draft declaration to request a change on 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') {
|
||||
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
'DUTY_DISPUTE',
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
userId,
|
||||
);
|
||||
// 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.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
|
||||
// Back to GL: reopening the milestone is what re-arms the step (the
|
||||
// stepper picks its active step from milestone completion).
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.dutyDisputed(updated, note.trim());
|
||||
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -824,6 +936,10 @@ export class BookingClearanceService {
|
||||
'RELEASE_ORDER_SECURED',
|
||||
userId,
|
||||
);
|
||||
// Release Order is now the last GL DJ pre-operation action (it follows the
|
||||
// declaration) — release immediately so booking creation unlocks without a
|
||||
// separate confirm click.
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The duty advice → dispute → re-advice loop, at the booking level. 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('BookingClearanceService — duty dispute', () => {
|
||||
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||
({
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-2026-00042',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractId: 'ctr-1',
|
||||
dutyRequired: true,
|
||||
...over,
|
||||
}) as Booking;
|
||||
|
||||
const milestone = (code: string, status: string) =>
|
||||
({ milestoneCode: code, status }) as never;
|
||||
|
||||
let repo: {
|
||||
createReviewNote: jest.Mock;
|
||||
findReviewNotes: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let bookingsService: { findById: jest.Mock };
|
||||
let workflowService: { listMilestonesForBooking: jest.Mock };
|
||||
let milestoneService: { reopenForBooking: jest.Mock };
|
||||
let notifier: { dutyDisputed: jest.Mock };
|
||||
let service: BookingClearanceService;
|
||||
|
||||
const build = (milestones: unknown[]) => {
|
||||
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
|
||||
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
|
||||
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
|
||||
notifier = { dutyDisputed: jest.fn() };
|
||||
|
||||
service = new BookingClearanceService(
|
||||
repo as never,
|
||||
bookingsService as never,
|
||||
{} 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('bk-1', ' Declared value is wrong ', 'user-1');
|
||||
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'Declared value is wrong',
|
||||
'DUTY_DISPUTE',
|
||||
'user-1',
|
||||
);
|
||||
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(repo.update).toHaveBeenCalledWith('bk-1', {
|
||||
clearanceCurrentPhase: 'GL_ET_OUTPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('tells GL Ethiopia, not the customer', async () => {
|
||||
await service.disputeDuty('bk-1', 'Too high', 'user-1');
|
||||
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'bk-1' }),
|
||||
'Too high',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when nothing has been advised yet', async () => {
|
||||
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||
await expect(service.disputeDuty('bk-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('bk-1', 'Too high')).rejects.toThrow(
|
||||
/already been submitted/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses when duty was never required for this clearance', async () => {
|
||||
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
|
||||
await expect(service.disputeDuty('bk-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('bk-1', milestones);
|
||||
|
||||
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||
{ note: '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([
|
||||
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
]);
|
||||
|
||||
expect(dispute).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
|
||||
@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
|
||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
|
||||
: 'Proceed to request operation';
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone(EXPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** Offload stats for the linked booking (null until one exists). */
|
||||
offload?: ClearanceOffloadState | null;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
@@ -165,6 +169,7 @@ export class ContractClearanceService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
@@ -433,6 +438,9 @@ export class ContractClearanceService {
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
offload: cycle?.bookingId
|
||||
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
|
||||
: null,
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
@@ -1117,20 +1125,18 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* GL Djibouti picks the transit officer from the admin-managed roster —
|
||||
* rejected unless the agent is active and inside its validity window.
|
||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||
* the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(
|
||||
contractId: string,
|
||||
assignee: string,
|
||||
transitAgentId: 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) {
|
||||
@@ -1138,16 +1144,17 @@ export class ContractClearanceService {
|
||||
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
|
||||
|
||||
const previous = cycle.transitAssigneeName ?? null;
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
|
||||
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1193,15 @@ export class ContractClearanceService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||
// The draft-declaration accept/change-request loop only exists on the
|
||||
// booking-scoped clearance page (portal customers never see contract-scoped
|
||||
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
|
||||
// flow, which has no UI to complete it. Contracts seeded before this step
|
||||
// existed have no such row to skip — ignore, `assertPriorComplete` below
|
||||
// already tolerates a missing milestone as "not required".
|
||||
await this.workflowService
|
||||
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
|
||||
.catch(() => undefined);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
@@ -1215,12 +1231,6 @@ export class ContractClearanceService {
|
||||
});
|
||||
}
|
||||
|
||||
// Export: the declaration is the last GL ET pre-booking action — release
|
||||
// immediately so booking creation unlocks without a separate confirm click.
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -1561,6 +1571,10 @@ export class ContractClearanceService {
|
||||
currentPhase: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
|
||||
// Release Order is now the last GL DJ pre-booking action (it follows the
|
||||
// declaration) — release immediately so booking creation unlocks without a
|
||||
// separate confirm click.
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
|
||||
return { contract: await this.contractsService.findById(contractId), hold: false };
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // dropdownSettingsService
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
|
||||
@@ -837,16 +837,16 @@ export class ContractsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
assignTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.assignTransitAssignee(
|
||||
id,
|
||||
assignee,
|
||||
transitAgentId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
@@ -1294,6 +1294,20 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice/approve')
|
||||
@ApiOperation({
|
||||
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
|
||||
})
|
||||
approveFinalInvoice(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.approveFinalInvoice(
|
||||
bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
|
||||
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeController } from './gl-exchange.controller';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
|
||||
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// Provides the admin-editable contract document templates consumed by
|
||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||
ContractTemplatesModule,
|
||||
TransitAgentsModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
forwardRef(() => BookingsModule),
|
||||
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [ContractsController],
|
||||
controllers: [ContractsController, GlExchangeController],
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
GlExchangeService,
|
||||
BookingRequestService,
|
||||
BookingRequestRepository,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlExchangeService,
|
||||
],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
|
||||
declarationSerial?: string;
|
||||
/** When the gate pass was physically granted (GL DJ captures the time). */
|
||||
gatepassAt?: string;
|
||||
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
|
||||
draftDeclarationPrice?: number;
|
||||
draftDeclarationCurrency?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
|
||||
* (which issues it) before a payment slip is accepted.
|
||||
*/
|
||||
describe('GlOperationsService — final invoice approval', () => {
|
||||
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
|
||||
id: 'inv-1',
|
||||
invoiceNumber: 'INV-1',
|
||||
status,
|
||||
totalAmount: 1500,
|
||||
currency: 'ETB',
|
||||
issuedAt,
|
||||
paidAt: null,
|
||||
});
|
||||
|
||||
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
|
||||
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
|
||||
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
|
||||
let service: GlOperationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
billingService = {
|
||||
findInvoice: jest.fn(),
|
||||
updateStatus: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
filesService = {
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
upsertByCode: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
notifier = {
|
||||
finalInvoiceApprovedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === Booking
|
||||
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
|
||||
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
|
||||
};
|
||||
|
||||
service = new GlOperationsService(
|
||||
dataSource as never,
|
||||
filesService as never,
|
||||
{} as never, // milestoneService
|
||||
billingService as never,
|
||||
notifier as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('issues the draft on customer approval and reports approvedAt', async () => {
|
||||
const issued = new Date('2026-07-28T09:00:00.000Z');
|
||||
billingService.findInvoice
|
||||
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
|
||||
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
|
||||
|
||||
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
|
||||
|
||||
expect(billingService.updateStatus).toHaveBeenCalledWith(
|
||||
'inv-1',
|
||||
Freight.InvoiceStatus.Issued,
|
||||
);
|
||||
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
|
||||
expect(summary.approvedAt).toBe(issued.toISOString());
|
||||
});
|
||||
|
||||
it('is a no-op when the invoice was already approved', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(
|
||||
invoice(Freight.InvoiceStatus.Issued, new Date()),
|
||||
);
|
||||
|
||||
await service.approveFinalInvoice('bk-1');
|
||||
|
||||
expect(billingService.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a payment slip while the invoice is still a draft', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
|
||||
|
||||
await expect(
|
||||
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(filesService.upsertByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the payment slip once approved', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(
|
||||
invoice(Freight.InvoiceStatus.Issued, new Date()),
|
||||
);
|
||||
|
||||
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
|
||||
|
||||
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'final_invoice_slip' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
|
||||
import {
|
||||
GlExchangeService,
|
||||
type GlExchangeActor,
|
||||
type GlExchangeSide,
|
||||
} from './gl-exchange.service';
|
||||
|
||||
/** Either GL desk may read and post; ownership decides who may edit. */
|
||||
const GL_EXCHANGE_PERMS = [
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
];
|
||||
|
||||
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
|
||||
const asBool = (raw: string | boolean | undefined): boolean =>
|
||||
raw === true || raw === 'true' || raw === '1';
|
||||
|
||||
@ApiTags('gl-exchange')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gl-exchange')
|
||||
export class GlExchangeController {
|
||||
constructor(private readonly exchangeService: GlExchangeService) {}
|
||||
|
||||
@Get(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
|
||||
})
|
||||
list(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.list(entityId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Share a document with the other GL desk' })
|
||||
upload(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.upload(
|
||||
entityId,
|
||||
file,
|
||||
{ title, visibleToCustomer: asBool(visibleToCustomer) },
|
||||
this.actor(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('documents/:documentId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Uploader edits a shared document (title, visibility, file)',
|
||||
})
|
||||
update(
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string | undefined,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.update(
|
||||
documentId,
|
||||
{
|
||||
title,
|
||||
visibleToCustomer:
|
||||
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
|
||||
},
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('documents/:documentId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Uploader removes a shared document' })
|
||||
async remove(
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which desk is posting. A user holding only the Djibouti actions permission
|
||||
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
|
||||
* posts as Ethiopia.
|
||||
*/
|
||||
private actor(user: TCurrentUser): GlExchangeActor {
|
||||
const side: GlExchangeSide =
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
? 'DJ'
|
||||
: 'ET';
|
||||
return {
|
||||
userId: resolveAuthUserId(user),
|
||||
name: actorLabel(user) ?? null,
|
||||
side,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import type { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/**
|
||||
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
|
||||
* thread is keyed by the entity the two desks are working on — a booking id on
|
||||
* the per-booking clearance pages, a contract id on the pre-booking ones — so
|
||||
* both desks opening the same record see the same documents.
|
||||
*/
|
||||
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
||||
|
||||
export type GlExchangeSide = 'ET' | 'DJ';
|
||||
|
||||
export interface GlExchangeActor {
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
side: GlExchangeSide;
|
||||
}
|
||||
|
||||
export interface GlExchangeUploadInput {
|
||||
title: string;
|
||||
visibleToCustomer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-form document exchange between the two Global Logistics desks. Anything
|
||||
* either side needs the other to have (scans, correspondence, corrected forms)
|
||||
* lands here under a title they choose, instead of a fixed clearance slot.
|
||||
*
|
||||
* Rules, all enforced here rather than in the UI:
|
||||
* - both desks read every document in a thread, whoever uploaded it;
|
||||
* - only the uploader may retitle, replace or remove one;
|
||||
* - the customer sees only what its uploader marked visible.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GlExchangeService {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
/** Every document on one thread, newest first, from a GL desk's view. */
|
||||
async list(
|
||||
entityId: string,
|
||||
viewerId: string,
|
||||
): Promise<Freight.GlExchangeDocument[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
entityId,
|
||||
GL_EXCHANGE_RESOURCE,
|
||||
);
|
||||
return this.sort(records.map((r) => this.toDto(r, viewerId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer-facing slice across several threads (a booking and the
|
||||
* contract it belongs to). Never exposes internal documents, and never marks
|
||||
* anything editable — the customer is not a GL desk.
|
||||
*/
|
||||
async listVisibleToCustomer(
|
||||
entityIds: string[],
|
||||
): Promise<Freight.GlExchangeDocument[]> {
|
||||
const ids = [...new Set(entityIds.filter(Boolean))];
|
||||
if (ids.length === 0) return [];
|
||||
const grouped = await this.filesService.findByResourceIdsGrouped(
|
||||
ids,
|
||||
GL_EXCHANGE_RESOURCE,
|
||||
);
|
||||
const visible = [...grouped.values()]
|
||||
.flat()
|
||||
.filter((r) => r.visibleToCustomer);
|
||||
return this.sort(visible.map((r) => this.toDto(r, null)));
|
||||
}
|
||||
|
||||
async upload(
|
||||
entityId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
input: GlExchangeUploadInput,
|
||||
actor: GlExchangeActor,
|
||||
): Promise<Freight.GlExchangeDocument> {
|
||||
const title = input.title?.trim();
|
||||
if (!title) throw new BadRequestException('A document title is required.');
|
||||
if (!file) throw new BadRequestException('A file is required.');
|
||||
|
||||
const record = await this.filesService.upload({
|
||||
resourceId: entityId,
|
||||
resource: GL_EXCHANGE_RESOURCE,
|
||||
// No fixed slot exists for these — `code` carries the uploading desk, so
|
||||
// a document's origin survives even if the uploader leaves the org.
|
||||
code: actor.side,
|
||||
file,
|
||||
title,
|
||||
visibleToCustomer: input.visibleToCustomer,
|
||||
uploadedByUserId: actor.userId,
|
||||
uploadedByName: actor.name ?? null,
|
||||
});
|
||||
return this.toDto(record, actor.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitle, re-share or replace a document. Uploader only — the other desk
|
||||
* reads it but never edits it. A replacement file supersedes the old record
|
||||
* (soft-deleted, bytes kept) and carries its metadata forward.
|
||||
*/
|
||||
async update(
|
||||
documentId: string,
|
||||
patch: { title?: string; visibleToCustomer?: boolean },
|
||||
file: Express.Multer.File | undefined,
|
||||
actorId: string,
|
||||
): Promise<Freight.GlExchangeDocument> {
|
||||
const record = await this.assertUploader(documentId, actorId);
|
||||
const title = patch.title?.trim();
|
||||
if (patch.title != null && !title) {
|
||||
throw new BadRequestException('A document title is required.');
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const replacement = await this.filesService.upload({
|
||||
resourceId: record.resourceId,
|
||||
resource: GL_EXCHANGE_RESOURCE,
|
||||
code: record.code,
|
||||
file,
|
||||
title: title ?? record.title,
|
||||
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
});
|
||||
await this.filesService.remove(record.id);
|
||||
return this.toDto(replacement, actorId);
|
||||
}
|
||||
|
||||
const updated = await this.filesService.updateMeta(record.id, {
|
||||
...(title ? { title } : {}),
|
||||
...(patch.visibleToCustomer != null
|
||||
? { visibleToCustomer: patch.visibleToCustomer }
|
||||
: {}),
|
||||
});
|
||||
return this.toDto(updated, actorId);
|
||||
}
|
||||
|
||||
/** Uploader-only removal (soft delete — the stored bytes are kept). */
|
||||
async remove(documentId: string, actorId: string): Promise<void> {
|
||||
const record = await this.assertUploader(documentId, actorId);
|
||||
await this.filesService.remove(record.id);
|
||||
}
|
||||
|
||||
private async assertUploader(
|
||||
documentId: string,
|
||||
actorId: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesService.findById(documentId);
|
||||
if (record.resource !== GL_EXCHANGE_RESOURCE) {
|
||||
throw new NotFoundException(`Exchange document ${documentId} not found`);
|
||||
}
|
||||
if (record.uploadedByUserId !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
'Only the person who uploaded this document can change it.',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private sort(
|
||||
docs: Freight.GlExchangeDocument[],
|
||||
): Freight.GlExchangeDocument[] {
|
||||
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
|
||||
}
|
||||
|
||||
private toDto(
|
||||
record: FileRecord,
|
||||
viewerId: string | null,
|
||||
): Freight.GlExchangeDocument {
|
||||
return {
|
||||
id: record.id,
|
||||
entityId: record.resourceId,
|
||||
// Pre-title rows (none in practice) fall back to the filename so a list
|
||||
// never renders a blank row.
|
||||
title: record.title ?? record.name,
|
||||
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
||||
visibleToCustomer: record.visibleToCustomer,
|
||||
uploadedById: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
file: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
},
|
||||
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,85 @@ export class GlOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offload facts for a booking, read-only: what came off the train at its
|
||||
* destination (containers, wagons, tonnes) and where the goods went. Sourced
|
||||
* from the booking's warehouse-inventory row — written by the auto-unload
|
||||
* that runs on train arrival for both directions.
|
||||
*/
|
||||
async offloadState(
|
||||
bookingId: string,
|
||||
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
|
||||
): Promise<Freight.ClearanceOffloadState> {
|
||||
const [row]: Array<{
|
||||
destination: string | null;
|
||||
containers: number;
|
||||
wagons: number;
|
||||
bookedWeight: string | null;
|
||||
inventoryStatus: string | null;
|
||||
unloadedAt: Date | null;
|
||||
grnNumber: string | null;
|
||||
offloadedWeight: string | null;
|
||||
warehouse: string | null;
|
||||
warehouseYard: string | null;
|
||||
zone: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(dy.label, dy.code) AS "destination",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
|
||||
b.cargo_total_weight_vgm AS "bookedWeight",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.unloaded_at AS "unloadedAt",
|
||||
inv.grn_number AS "grnNumber",
|
||||
inv.weight AS "offloadedWeight",
|
||||
wh.name AS "warehouse",
|
||||
wy.name AS "warehouseYard",
|
||||
wz.name AS "zone"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT i.*
|
||||
FROM freight.warehouse_inventory i
|
||||
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
|
||||
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
|
||||
LIMIT 1
|
||||
) inv ON TRUE
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
|
||||
const offloadedAt =
|
||||
milestone?.status === 'COMPLETED' && milestone.triggeredAt
|
||||
? new Date(milestone.triggeredAt).toISOString()
|
||||
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
|
||||
// The warehouse records the real offloaded tonnage; before it does, the
|
||||
// booked VGM is the best number we have.
|
||||
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
|
||||
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › ');
|
||||
|
||||
return {
|
||||
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
|
||||
offloadedAt,
|
||||
destination: row?.destination ?? null,
|
||||
containers: row?.containers ?? 0,
|
||||
wagons: row?.wagons ?? 0,
|
||||
weightTons: weight || null,
|
||||
grnNumber: row?.grnNumber ?? null,
|
||||
location: location || null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
@@ -381,8 +460,9 @@ export class GlOperationsService {
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
* attached invoice document. The customer pays offline and attaches a slip;
|
||||
* GL (ET or DJ) then confirms to settle it.
|
||||
* attached invoice document. It is issued as a DRAFT the customer must approve
|
||||
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
|
||||
* confirms to settle it.
|
||||
*/
|
||||
async createFinalInvoice(
|
||||
bookingId: string,
|
||||
@@ -445,7 +525,8 @@ export class GlOperationsService {
|
||||
amount: input.amount,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
// DRAFT until the customer approves it — approveFinalInvoice issues it.
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
});
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
@@ -467,6 +548,40 @@ export class GlOperationsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer approves the drafted final invoice — issues it, which is what
|
||||
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
|
||||
*/
|
||||
async approveFinalInvoice(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been raised for this shipment.');
|
||||
}
|
||||
if (
|
||||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||
invoice.status === Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new BadRequestException('The final invoice is no longer payable.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
|
||||
this.notifier.finalInvoiceApprovedToStaff(booking);
|
||||
}
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the final invoice. */
|
||||
async uploadFinalInvoiceSlip(
|
||||
bookingId: string,
|
||||
@@ -483,6 +598,11 @@ export class GlOperationsService {
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
'Approve the final invoice before attaching a payment slip.',
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('The final invoice is already paid.');
|
||||
}
|
||||
@@ -688,6 +808,8 @@ export class GlOperationsService {
|
||||
description: line?.description ?? null,
|
||||
invoiceFile: toRef('final_invoice'),
|
||||
slipFile: toRef('final_invoice_slip'),
|
||||
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
|
||||
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
|
||||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
declarationFileLabel,
|
||||
draftDeclarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isDraftDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one draft declaration file in the upload batch. */
|
||||
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No draft declaration documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
|
||||
export function normalizeDraftDeclarationFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `draft_declaration_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
|
||||
export async function persistDraftDeclarationUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDraftDeclarationFieldNames(files);
|
||||
assertDraftDeclarationFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `draft_declaration_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one transit permit file in the upload batch. */
|
||||
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
|
||||
});
|
||||
});
|
||||
|
||||
const extraDraftDeclarations = files
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraDraftDeclarations.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: draftDeclarationFileLabel(index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'draft_declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
const extraTransit = files
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeRequested: jest.Mock;
|
||||
transitAssigneeAssigned: jest.Mock;
|
||||
};
|
||||
let transitAgentsService: { getAssignable: jest.Mock };
|
||||
let service: ContractClearanceService;
|
||||
|
||||
const cycle = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
};
|
||||
transitAgentsService = {
|
||||
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
|
||||
};
|
||||
service = new ContractClearanceService(
|
||||
repo as never,
|
||||
contractsService as never,
|
||||
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
|
||||
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
|
||||
|
||||
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
|
||||
const patch = repo.updateCycle.mock.calls[0][1];
|
||||
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
|
||||
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
|
||||
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
}),
|
||||
);
|
||||
transitAgentsService.getAssignable.mockResolvedValue({
|
||||
id: 'agent-2',
|
||||
name: 'Fatouma Ali',
|
||||
});
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
|
||||
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
|
||||
|
||||
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an empty name', async () => {
|
||||
it('refuses a suspended or out-of-window agent', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
transitAgentsService.getAssignable.mockRejectedValue(
|
||||
new BadRequestException('suspended'),
|
||||
);
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
|
||||
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses before Ethiopia has asked', async () => {
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
|
||||
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
|
||||
).rejects.toThrow(/not requested/i);
|
||||
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
|
||||
/** Why the file was replaced — shown on the document's version history. */
|
||||
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||
replaceReason!: string | null;
|
||||
|
||||
/**
|
||||
* Free-text label chosen by the uploader, when the document has no fixed slot
|
||||
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
|
||||
* catalog-driven upload, whose label comes from its code.
|
||||
*/
|
||||
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
|
||||
title!: string | null;
|
||||
|
||||
/** Uploader's choice to share the document with the customer's portal. */
|
||||
@Column({ name: "visible_to_customer", type: "boolean", default: false })
|
||||
visibleToCustomer!: boolean;
|
||||
|
||||
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
|
||||
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
|
||||
uploadedByUserId!: string | null;
|
||||
|
||||
/** Uploader's display name, resolved once so lists need no IAM lookup. */
|
||||
@Column({
|
||||
name: "uploaded_by_name",
|
||||
type: "varchar",
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
uploadedByName!: string | null;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface CreateFileInput {
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
|
||||
title?: string | null;
|
||||
visibleToCustomer?: boolean;
|
||||
uploadedByUserId?: string | null;
|
||||
uploadedByName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,9 +106,27 @@ export class FilesService {
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
title: input.title ?? null,
|
||||
visibleToCustomer: input.visibleToCustomer ?? false,
|
||||
uploadedByUserId: input.uploadedByUserId ?? null,
|
||||
uploadedByName: input.uploadedByName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the uploader-authored metadata of a stored file (title, customer
|
||||
* visibility). Bytes are untouched — callers replacing content upload a new
|
||||
* record instead.
|
||||
*/
|
||||
async updateMeta(
|
||||
id: string,
|
||||
patch: { title?: string; visibleToCustomer?: boolean },
|
||||
): Promise<FileRecord> {
|
||||
const updated = await this.filesRepository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`File ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||
|
||||
@@ -327,6 +327,8 @@ export class LastMileService {
|
||||
*/
|
||||
async arrivalTrucksForBooking(bookingId: string): Promise<
|
||||
Array<{
|
||||
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -359,6 +361,7 @@ export class LastMileService {
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -386,6 +389,7 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
lastMileId: lm.id,
|
||||
vehicleId: vehicle.id,
|
||||
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
|
||||
trailerPlateNumber: vehicle.trailerPlateNo || null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
forwardRef,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Consumer side of the payment microservice's outbox relay. Only the payment service may
|
||||
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@Public()
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
private readonly logger = new Logger(InternalPaymentController.name);
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly paymentService: PaymentService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -36,4 +48,16 @@ export class InternalPaymentController {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.billingService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,24 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
|
||||
payerName?: string;
|
||||
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
CBE_BILL: "cbe-bill",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -190,8 +195,13 @@ export class PaymentService {
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
try {
|
||||
|
||||
|
||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for ETB invoices",
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -199,11 +209,15 @@ export class PaymentService {
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// amountMinor: input.amountMinor,
|
||||
amountMinor:1,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the 1-birr dev shortcut would break it.
|
||||
amountMinor: isCbeBill ? input.amountMinor : 1,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
|
||||
@@ -60,8 +60,10 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@@ -80,6 +82,17 @@ export class ClientActionDto {
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
TrainSchedulingService,
|
||||
effectiveWindowConfig,
|
||||
} from './train-scheduling.service';
|
||||
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
/**
|
||||
* 0-based booking-window cycle this booking entered the pool in (derived from
|
||||
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
|
||||
* bookings within a cycle only — an earlier cycle always boards before a later
|
||||
* one regardless of score. Null while the contract is still pending.
|
||||
*/
|
||||
windowCycleNo: number | null;
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
const cycleOf = await this.windowCycleIndexer(s);
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonDims);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
|
||||
// must rank bulk bookings by their wagon-derived priority too.
|
||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||
this.resortPoolByPriority(pool);
|
||||
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let preempted = false;
|
||||
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
armed: boolean;
|
||||
changed: boolean;
|
||||
}> = [];
|
||||
// The day group shares one booking window (route+day grouping), so any
|
||||
// member's window grid stands for the pool's cycle derivation.
|
||||
let cycleSchedule: TrainSchedule | null = null;
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cycleSchedule ??= schedule;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// BULK bookings only get their real (wagon-derived) priority score now, at
|
||||
// batch time — stamp it and re-rank before the fill consumes the pool.
|
||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||
this.resortPoolByPriority(pool);
|
||||
this.resortPoolByPriority(
|
||||
pool,
|
||||
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
|
||||
);
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
@@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
|
||||
private resortPoolByPriority(pool: Booking[]): void {
|
||||
/**
|
||||
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
|
||||
* booking-window cycle it arrived in: the last window whose open is at/before
|
||||
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
|
||||
* cycle that just closed). The cycle grid comes from the schedule's frozen
|
||||
* window-rule snapshot — the exact windows the cycle engine runs.
|
||||
*/
|
||||
private async windowCycleIndexer(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<(ts: Date | null | undefined) => number> {
|
||||
if (!schedule.scheduledDepartureDate) return () => 0;
|
||||
let starts: number[];
|
||||
try {
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const cfg = effectiveWindowConfig(schedule, liveCfg);
|
||||
const windows = listConfigBookingWindows(
|
||||
schedule.direction,
|
||||
schedule.scheduledDepartureDate,
|
||||
{
|
||||
...cfg,
|
||||
reopenGapMinutes:
|
||||
schedule.ruleReopenDelayMinutes ??
|
||||
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||||
},
|
||||
);
|
||||
starts = windows.map((w) => w.start.getTime());
|
||||
} catch (err) {
|
||||
// A failed cycle derivation must never block the batch — fall back to one
|
||||
// flat cycle (pure priority order, the old behaviour).
|
||||
this.logger.warn(
|
||||
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
|
||||
`${(err as Error).message}`,
|
||||
);
|
||||
return () => 0;
|
||||
}
|
||||
return (ts) => {
|
||||
if (!ts) return 0;
|
||||
const ms = ts.getTime();
|
||||
let idx = 0;
|
||||
for (let i = 0; i < starts.length; i += 1) {
|
||||
if (ms >= starts[i]) idx = i;
|
||||
}
|
||||
return idx;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
|
||||
* only within the cycle they arrived in — an earlier cycle's booking always
|
||||
* outranks a later cycle's, whatever the scores), then priority score, then
|
||||
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
|
||||
*/
|
||||
private resortPoolByPriority(
|
||||
pool: Booking[],
|
||||
cycleOf: (ts: Date | null | undefined) => number = () => 0,
|
||||
): void {
|
||||
pool.sort(
|
||||
(a, b) =>
|
||||
Number(b.isGovernment) - Number(a.isGovernment) ||
|
||||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
|
||||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
|
||||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
|
||||
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
|
||||
|
||||
@@ -2754,11 +2754,13 @@ export class TrainSchedulingService {
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
booking: allocation.booking,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
containerItems: allocation.containerItems ?? [],
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
@@ -2839,6 +2841,7 @@ export class TrainSchedulingService {
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||||
@@ -2847,6 +2850,7 @@ export class TrainSchedulingService {
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
@@ -2861,6 +2865,18 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
);
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
(wagon.allocations ?? []).forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
const size = item.bookingContainer?.containerSize;
|
||||
if (size?.includes('40')) count40ft++;
|
||||
else if (size?.includes('20')) count20ft++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -2910,6 +2926,9 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
@@ -2928,6 +2947,7 @@ export class TrainSchedulingService {
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Company</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
<th>Seal No</th>
|
||||
@@ -3010,6 +3030,19 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
loadList.wagons.forEach((wagon) => {
|
||||
wagon.allocations.forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
const size = item.bookingContainer?.containerSize;
|
||||
if (size?.includes('40')) count40ft++;
|
||||
else if (size?.includes('20')) count20ft++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
@@ -3025,13 +3058,17 @@ export class TrainSchedulingService {
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
(allocation) => {
|
||||
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
</tr>`;
|
||||
},
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
@@ -3096,6 +3133,9 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
@@ -3115,6 +3155,7 @@ export class TrainSchedulingService {
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Booking</th>
|
||||
<th>Company</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th class="num">Weight T</th>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTransitAgentDto {
|
||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-01' })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31' })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
||||
@Index(['isActive'])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'date' })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: 'valid_to', type: 'date' })
|
||||
validTo!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@ApiTags('transit-agents')
|
||||
@Controller('transit-agents')
|
||||
@ApiBearerAuth()
|
||||
export class TransitAgentsController {
|
||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.transitAgentsService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
: undefined,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
@Get('assignable')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('transit-agents')
|
||||
@ApiOperation({ summary: 'Create a transit agent' })
|
||||
create(@Body() dto: CreateTransitAgentDto) {
|
||||
return this.transitAgentsService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('transit-agents')
|
||||
@ApiOperation({ summary: 'Update a transit agent' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
||||
return this.transitAgentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('transit-agents')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsController } from './transit-agents.controller';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransitAgent])],
|
||||
controllers: [TransitAgentsController],
|
||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||
})
|
||||
export class TransitAgentsModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
constructor(
|
||||
@InjectRepository(TransitAgent)
|
||||
repository: Repository<TransitAgent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
isActive: true,
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
|
||||
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
};
|
||||
|
||||
type TransitAgentListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
||||
if (today > agent.validTo) return 'EXPIRED';
|
||||
return 'VALID';
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
return { ...agent, validityStatus: validityStatus(agent) };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsService {
|
||||
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
|
||||
|
||||
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||
data: TransitAgentView[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: 'name';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map(withValidityStatus),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
||||
async findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransitAgentView> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new BadRequestException('Selected transit officer was not found.');
|
||||
}
|
||||
if (!agent.isActive) {
|
||||
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
|
||||
}
|
||||
if (validityStatus(agent) !== 'VALID') {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.create({
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
|
||||
const updated = await this.transitAgentsRepository.update(id, {
|
||||
...dto,
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(updated);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.transitAgentsRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -3679,6 +3679,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -3696,6 +3697,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
delivered: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
@@ -3710,7 +3712,8 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "bookingReference",
|
||||
b.contract_id AS "contractId",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
|
||||
inv.inspection_status AS "inspectionStatus"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
@@ -3762,6 +3765,7 @@ export class WarehouseInventoryService {
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
inspectionStatus: r.inspectionStatus,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -164,20 +164,25 @@ export class EdrOrgSeeder {
|
||||
manager: EntityManager,
|
||||
applicationId: string,
|
||||
) {
|
||||
const permissionRepository = manager.getRepository(Permission);
|
||||
|
||||
// Upsert by key so reruns are idempotent; applicationId ties every
|
||||
// permission to the EDR Freight application (also backfills rows that
|
||||
// were previously seeded without the relation).
|
||||
await permissionRepository.upsert(
|
||||
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
|
||||
id: permission.id,
|
||||
key: permission.key,
|
||||
name: { ...permission.name },
|
||||
applicationId,
|
||||
})),
|
||||
{ conflictPaths: { key: true } },
|
||||
);
|
||||
// iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT
|
||||
// can only target one. Seeding a hand-minted id that some older/retired key
|
||||
// already owns in an environment slips past ON CONFLICT (key) and dies on
|
||||
// the PK. The key is the identity every consumer resolves by (positions
|
||||
// seeder maps key -> id at runtime), so ids are left to the column default
|
||||
// and never sent — no id can collide.
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(Permission)
|
||||
.values(
|
||||
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
|
||||
key: permission.key,
|
||||
name: { ...permission.name },
|
||||
applicationId,
|
||||
})),
|
||||
)
|
||||
.orUpdate(["name", "application_id"], ["key"])
|
||||
.execute();
|
||||
|
||||
this.logger.log(
|
||||
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
|
||||
|
||||
describe('EDR_FREIGHT_PERMISSIONS', () => {
|
||||
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
|
||||
// statement — a duplicated key there is a Postgres 21000 at boot, not a
|
||||
// silent no-op.
|
||||
it('has no duplicate keys', () => {
|
||||
const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key);
|
||||
const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
|
||||
|
||||
expect(duplicates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
|
||||
// so a mid-list insert would shift ids already seeded for later slugs.
|
||||
'truck-types',
|
||||
'transit-agents',
|
||||
] as const;
|
||||
|
||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||
@@ -108,8 +109,12 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'),
|
||||
];
|
||||
|
||||
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||
// them by id, so re-minting would orphan those rows.
|
||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||
// lets the column default mint the uuid — so these are kept only as a record of
|
||||
// which ids each environment already holds. A hand-picked id must still never
|
||||
// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage`
|
||||
// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505
|
||||
// on every environment that still had the retired row.
|
||||
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
|
||||
'container-types': 'b2000001-0001-4000-8000-000000000003',
|
||||
@@ -123,6 +128,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||
rates: 'b2000001-0001-4000-8000-000000000011',
|
||||
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
|
||||
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
|
||||
'transit-agents': 'b2000003-0001-4000-8000-000000000001',
|
||||
};
|
||||
|
||||
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
|
||||
@@ -136,7 +142,7 @@ const ruleEngineCrudId = (
|
||||
): string => {
|
||||
const n =
|
||||
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
|
||||
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||
1; // 1..36
|
||||
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
|
||||
if (!status) return { text: "Pending", color: "gray" };
|
||||
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
|
||||
if (status === "FAILED") return { text: "Failed", color: "red" };
|
||||
return { text: status, color: "gray" };
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
warehouseArrived: string | null;
|
||||
warehouseDeparted: string | null;
|
||||
destinationArrived: string | null;
|
||||
returned: string | null;
|
||||
detentionOpen: boolean;
|
||||
detentionDays: number | null;
|
||||
detentionAmount: number | null;
|
||||
hasDetentionRule: boolean;
|
||||
inspection: { text: string; color: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every truck tied to a booking's last mile — EDR-dispatched or customer
|
||||
* self-haul (a booking only ever uses one), each with its own warehouse-gate
|
||||
* and destination-detention clocks, plus the booking's cargo-side cost totals
|
||||
* (storage/demurrage/double handling — billed per row internally, always
|
||||
* shown here as one booking-level total). Detention stays EDR-only; customer
|
||||
* self-haul rows show "—" since EDR only bills detention on its own fleet.
|
||||
*/
|
||||
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const [feeModalOpen, setFeeModalOpen] = useState(false);
|
||||
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
|
||||
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
);
|
||||
const inventoryItems = inventoryQuery.data ?? [];
|
||||
const latestInventory = inventoryItems[0] ?? null;
|
||||
|
||||
const edrTrucksQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
|
||||
});
|
||||
const edrTrucks = edrTrucksQuery.data ?? [];
|
||||
|
||||
const customerTrucksQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
|
||||
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerTrucksQuery.data ?? [];
|
||||
|
||||
const mode: "EDR" | "CUSTOMER" | "NONE" =
|
||||
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
|
||||
|
||||
const containerItemsQuery = useQuery({
|
||||
queryKey: ["booking-container-items-for-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId),
|
||||
});
|
||||
const inspectionByContainer = new Map(
|
||||
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
|
||||
);
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionPreviewQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionPreview = detentionPreviewQuery.data;
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
|
||||
// Booking-level cost strip: same per-row fee preview the accrual dashboard
|
||||
// and FeePreviewModal already use, summed across every inventory row on
|
||||
// this booking rather than duplicated per row.
|
||||
const feeQueries = useQueries({
|
||||
queries: inventoryItems.map((item) =>
|
||||
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
|
||||
),
|
||||
});
|
||||
const allFees = feeQueries.flatMap((q) => q.data ?? []);
|
||||
const feeCurrency = allFees[0]?.currency ?? "USD";
|
||||
const sumByType = (type: string) =>
|
||||
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
|
||||
|
||||
const rows: TruckRow[] = useMemo(() => {
|
||||
if (mode === "EDR") {
|
||||
return edrTrucks.map((t) => {
|
||||
const g = detentionByVehicle.get(t.vehicleId);
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers: t.containerNumber ? [t.containerNumber] : [],
|
||||
warehouseArrived: t.arrivedAt,
|
||||
warehouseDeparted: t.departedAt,
|
||||
destinationArrived: g?.startDate ?? null,
|
||||
returned: g?.endIsOpen ? null : g?.endDate ?? null,
|
||||
detentionOpen: Boolean(g?.endIsOpen),
|
||||
detentionDays: g?.chargeableDays ?? null,
|
||||
detentionAmount: g?.amount ?? null,
|
||||
hasDetentionRule: Boolean(g?.ruleId),
|
||||
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mode === "CUSTOMER") {
|
||||
return customerTrucks.map((t) => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
|
||||
const inspection =
|
||||
containers.length === 0
|
||||
? inspectionLabel(undefined)
|
||||
: statuses.size > 1
|
||||
? { text: "Mixed", color: "yellow" }
|
||||
: inspectionLabel([...statuses][0]);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
warehouseArrived: t.arrivedAt ?? null,
|
||||
warehouseDeparted: t.departedAt ?? null,
|
||||
destinationArrived: null,
|
||||
returned: null,
|
||||
detentionOpen: false,
|
||||
detentionDays: null,
|
||||
detentionAmount: null,
|
||||
hasDetentionRule: false,
|
||||
inspection,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
|
||||
|
||||
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading trucks…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Coins}
|
||||
title="Cargo costs"
|
||||
subtitle="Storage, demurrage & double handling — booking total"
|
||||
accent="teal"
|
||||
extra={
|
||||
latestInventory && (
|
||||
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
|
||||
View breakdown
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={Truck}
|
||||
title="Trucks"
|
||||
subtitle={
|
||||
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
|
||||
}
|
||||
accent="grape"
|
||||
extra={
|
||||
mode === "EDR" && (
|
||||
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
|
||||
Detention times
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trucks assigned to this booking's last mile yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Container(s)</Table.Th>
|
||||
<Table.Th>Wh. arrived</Table.Th>
|
||||
<Table.Th>Wh. departed</Table.Th>
|
||||
<Table.Th>Dest. arrived</Table.Th>
|
||||
<Table.Th>Returned</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{r.driver ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.truckType ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
|
||||
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.detentionOpen ? (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
still out
|
||||
</Badge>
|
||||
) : (
|
||||
fmt(r.returned)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{mode !== "EDR" || r.detentionDays == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
|
||||
{!r.hasDetentionRule && (
|
||||
<Text span size="xs" c="red">
|
||||
{" "}
|
||||
· no rule
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={r.inspection.color}>
|
||||
{r.inspection.text}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<FeePreviewModal
|
||||
opened={feeModalOpen}
|
||||
onClose={() => setFeeModalOpen(false)}
|
||||
inventoryId={latestInventory?.id ?? null}
|
||||
/>
|
||||
{mode === "EDR" && (
|
||||
<TruckDetentionModal
|
||||
opened={detentionModalOpen}
|
||||
onClose={() => setDetentionModalOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./BookingTrucksPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
|
||||
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
|
||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||
|
||||
export interface ClearanceOpsTabsProps {
|
||||
bookingId: string | undefined;
|
||||
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
|
||||
/** Phased customs workflow files — enables the Uploaded documents tab. */
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
showWorkflowFilesTab?: boolean;
|
||||
/**
|
||||
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
|
||||
* belongs on this page. Undefined hides the tab; it is also hidden from staff
|
||||
* who hold neither desk's clearance-actions permission.
|
||||
*/
|
||||
exchangeEntityId?: string;
|
||||
tradeDirection?: string;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
|
||||
clearanceTab,
|
||||
workflowFiles = [],
|
||||
showWorkflowFilesTab = false,
|
||||
exchangeEntityId,
|
||||
tradeDirection = "IMPORT",
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
|
||||
return true;
|
||||
}).length;
|
||||
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
|
||||
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
|
||||
const { user } = useAuth();
|
||||
const showExchange =
|
||||
Boolean(exchangeEntityId) &&
|
||||
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
|
||||
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
|
||||
|
||||
if (!hasTabs) {
|
||||
return <>{clearanceTab}</>;
|
||||
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
|
||||
Uploaded documents
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showExchange ? (
|
||||
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
|
||||
Document exchange
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showOpsTabs && riskMs ? (
|
||||
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||
Risk assignment
|
||||
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showExchange ? (
|
||||
<Tabs.Panel value="exchange">
|
||||
<GlExchangePanel entityId={exchangeEntityId!} />
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showOpsTabs && riskMs && bookingId ? (
|
||||
<Tabs.Panel value="risk">
|
||||
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||
|
||||
@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
|
||||
string
|
||||
> = {
|
||||
declaration: "Declaration",
|
||||
draft_declaration: "Draft declaration",
|
||||
duty: "Duty & taxes",
|
||||
transit: "Transit",
|
||||
djibouti: "Djibouti",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
|
||||
"draft_declaration",
|
||||
"declaration",
|
||||
"duty",
|
||||
"transit",
|
||||
|
||||
@@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({
|
||||
});
|
||||
}
|
||||
|
||||
// Every acted approval step, not just hazardous ones — this is the one
|
||||
// place the approval-time record shows up in the page's main content
|
||||
// (the sidebar's ContractApprovalStepsCard has the same times, but only
|
||||
// there, and only while the chain is still actionable).
|
||||
for (const step of contract.approvalSteps ?? []) {
|
||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
||||
if (!step.actedAt) continue;
|
||||
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
|
||||
items.push({
|
||||
key: `hazard-${step.id}`,
|
||||
key: `step-${step.id}`,
|
||||
at: step.actedAt,
|
||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
||||
color: step.status === "REJECTED" ? "red" : "orange",
|
||||
icon: Flame,
|
||||
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
|
||||
detail: step.note ?? undefined,
|
||||
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
|
||||
icon: hazard ? Flame : ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Repeat } from "lucide-react";
|
||||
import { Building2, Repeat, UserRound } from "lucide-react";
|
||||
|
||||
import {
|
||||
CONTRACT_STATUS_COLOR,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
contractCourt,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
interface ContractStatusBadgeProps {
|
||||
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */
|
||||
export function ContractCourtBadge({ status }: { status: string }) {
|
||||
const court = contractCourt(status);
|
||||
if (!court) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground" title="No party is awaited">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isCustomer = court === "customer";
|
||||
return (
|
||||
<Badge
|
||||
color={isCustomer ? "orange" : "edr-green"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={
|
||||
isCustomer ? <UserRound size={12} /> : <Building2 size={12} />
|
||||
}
|
||||
title={
|
||||
isCustomer
|
||||
? "Waiting on the customer to act"
|
||||
: "Waiting on EDR staff to act"
|
||||
}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{isCustomer ? "With customer" : "With EDR"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Receipt,
|
||||
Ship,
|
||||
Train,
|
||||
@@ -31,6 +32,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
type TransitPermitUploadedRow,
|
||||
@@ -41,9 +43,11 @@ import {
|
||||
} from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import {
|
||||
DeclarationStep,
|
||||
OffloadStep,
|
||||
StepStatus,
|
||||
isBookingMilestoneDone,
|
||||
isMilestoneDone,
|
||||
offloadSummary,
|
||||
type ClearanceViewLike,
|
||||
type MilestoneRow,
|
||||
} from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
@@ -59,7 +63,8 @@ function todayISODate(): string {
|
||||
|
||||
/**
|
||||
* Export customs flow, ordered per the stakeholder process:
|
||||
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
||||
* customer docs → transit assignee (DJ names officer) → declaration (ET,
|
||||
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
|
||||
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
|
||||
* → final invoice (DJ) + customer slip + GL confirm.
|
||||
@@ -71,9 +76,10 @@ export function computeExportActiveStep(
|
||||
): number {
|
||||
const released = Boolean(clearance.bookingReady || clearance.operationReady);
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
|
||||
if (!bookingCreated) return 3;
|
||||
if (!clearance.transitAssignee?.name) return 1;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2;
|
||||
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3;
|
||||
if (!bookingCreated) return 4;
|
||||
if (
|
||||
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
|
||||
!(
|
||||
@@ -81,14 +87,17 @@ export function computeExportActiveStep(
|
||||
clearance.train?.wagonAllocated
|
||||
)
|
||||
) {
|
||||
return 4;
|
||||
return 5;
|
||||
}
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
|
||||
if (!clearance.train?.arrivedAt) return 6;
|
||||
if (!clearance.t1Closed) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (clearance.finalInvoice?.status !== "PAID") return 9;
|
||||
return 10;
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6;
|
||||
if (!clearance.train?.arrivedAt) return 7;
|
||||
if (!clearance.t1Closed) return 8;
|
||||
if (!clearance.gatepassGranted) return 9;
|
||||
// Step 10 is the read-only Offload step. It never gates the flow: the final
|
||||
// invoice may be raised on a secured gate pass alone, so parking the stepper
|
||||
// there would hide the invoice actions whenever operations lag on the offload.
|
||||
if (clearance.finalInvoice?.status !== "PAID") return 11;
|
||||
return 12;
|
||||
}
|
||||
|
||||
export function exportTransitFilesFromWorkflow(
|
||||
@@ -168,6 +177,7 @@ export function ExportClearanceStepper({
|
||||
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
|
||||
Boolean(clearance.train?.wagonAllocated);
|
||||
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
|
||||
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -213,6 +223,58 @@ export function ExportClearanceStepper({
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Request transit assignee"
|
||||
description="Ask GL Djibouti to name the officer handling this shipment"
|
||||
icon={
|
||||
clearance.transitAssignee?.name ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{showEt && canEt ? (
|
||||
<TransitAssigneePanel
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={Boolean(clearance.transitAssignee?.name)}
|
||||
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
|
||||
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
|
||||
/>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs declaration"
|
||||
description="GL Ethiopia uploads — releases the export"
|
||||
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
|
||||
>
|
||||
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
|
||||
<Stack gap="sm">
|
||||
<DeclarationStep
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
replaceMode={declared}
|
||||
workflowFiles={workflowFiles}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={declared}
|
||||
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
|
||||
doneLabel="Declaration uploaded."
|
||||
/>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Release Order"
|
||||
description="GL Djibouti uploads RO + vessel date"
|
||||
@@ -255,31 +317,13 @@ export function ExportClearanceStepper({
|
||||
</Text>
|
||||
) : null}
|
||||
<StepStatus
|
||||
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
|
||||
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && released}
|
||||
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
|
||||
doneLabel="Release Order secured."
|
||||
doneLabel="Release Order secured — export released."
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs declaration"
|
||||
description="GL Ethiopia uploads — releases the export"
|
||||
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
|
||||
>
|
||||
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
|
||||
<Stack gap="sm">
|
||||
<DeclarationStep
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
replaceMode={declared}
|
||||
workflowFiles={workflowFiles}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
{declared && !released ? (
|
||||
{/* RO is secured but the auto-release never fired (legacy in-flight
|
||||
contracts from before the RO step auto-released). */}
|
||||
{isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? (
|
||||
<ConfirmExportReleaseFallback
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
@@ -287,12 +331,6 @@ export function ExportClearanceStepper({
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={declared && released}
|
||||
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
|
||||
doneLabel="Declaration uploaded — export released."
|
||||
/>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
@@ -432,6 +470,21 @@ export function ExportClearanceStepper({
|
||||
<GatepassStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Read-only: operations record the offload when the train is unloaded
|
||||
at the Djibouti port. Stats ride in the description so they stay
|
||||
visible after the flow moves on to the final invoice. */}
|
||||
<Stepper.Step
|
||||
label="Offload"
|
||||
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
|
||||
color={offloadDone ? undefined : "gray"}
|
||||
icon={<PackageOpen size={14} />}
|
||||
completedIcon={
|
||||
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
|
||||
}
|
||||
>
|
||||
<OffloadStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Final invoice & payment"
|
||||
description="GL Djibouti invoices after offload; customer pays"
|
||||
@@ -656,6 +709,8 @@ function FinalInvoiceStep({
|
||||
|
||||
const invoice = clearance.finalInvoice ?? null;
|
||||
const paid = invoice?.status === "PAID";
|
||||
// Raised as a draft — the customer approves it before paying.
|
||||
const approved = Boolean(invoice?.approvedAt);
|
||||
|
||||
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
|
||||
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
|
||||
@@ -684,7 +739,7 @@ function FinalInvoiceStep({
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
|
||||
{invoice.status}
|
||||
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
@@ -722,9 +777,11 @@ function FinalInvoiceStep({
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel={
|
||||
invoice.slipFile
|
||||
? "Payment slip attached — confirm to settle the invoice."
|
||||
: "Waiting for the customer to pay and attach the payment slip."
|
||||
!approved
|
||||
? "Waiting for the customer to review and approve the invoice."
|
||||
: invoice.slipFile
|
||||
? "Payment slip attached — confirm to settle the invoice."
|
||||
: "Waiting for the customer to pay and attach the payment slip."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
@@ -754,6 +811,7 @@ function FinalInvoiceStep({
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Send the final invoice to the customer if post-arrival charges apply (optional).
|
||||
The customer approves it before paying.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Share2,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
|
||||
import { glExchangeService } from "@/services/glExchange.service";
|
||||
|
||||
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
|
||||
{
|
||||
ET: { label: "GL Ethiopia", color: "edr-green" },
|
||||
DJ: { label: "GL Djibouti", color: "blue" },
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`;
|
||||
}
|
||||
|
||||
export interface GlExchangePanelProps {
|
||||
/** Booking or contract id both desks are working on — the thread key. */
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file
|
||||
* under a title of its own choosing; both desks see the whole thread, only the
|
||||
* uploader can change or remove what they posted, and each document is shared
|
||||
* with the customer's portal or kept between the desks.
|
||||
*/
|
||||
export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [formDoc, setFormDoc] = useState<
|
||||
Freight.GlExchangeDocument | "new" | null
|
||||
>(null);
|
||||
const [pendingDelete, setPendingDelete] =
|
||||
useState<Freight.GlExchangeDocument | null>(null);
|
||||
|
||||
const {
|
||||
data: documents = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["gl-exchange", entityId],
|
||||
queryFn: () => glExchangeService.list(entityId),
|
||||
enabled: Boolean(entityId),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] });
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => glExchangeService.remove(id),
|
||||
onSuccess: async () => {
|
||||
setPendingDelete(null);
|
||||
await invalidate();
|
||||
toast.success("Document removed");
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not remove document"),
|
||||
});
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
et: documents.filter((d) => d.side === "ET").length,
|
||||
dj: documents.filter((d) => d.side === "DJ").length,
|
||||
shared: documents.filter((d) => d.visibleToCustomer).length,
|
||||
}),
|
||||
[documents],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
|
||||
<Share2 size={20} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} fz={16}>
|
||||
Document exchange
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Share any document with the other Global Logistics desk. Both
|
||||
desks see everything here; only the uploader can edit or remove
|
||||
a document, and only documents marked visible reach the customer.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setFormDoc("new")}
|
||||
>
|
||||
Share document
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{documents.length > 0 ? (
|
||||
<Group gap={8} mt="md">
|
||||
<Badge variant="light" color="edr-green" radius="sm" tt="none">
|
||||
{stats.et} from GL Ethiopia
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue" radius="sm" tt="none">
|
||||
{stats.dj} from GL Djibouti
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="none">
|
||||
{stats.shared} visible to customer
|
||||
</Badge>
|
||||
</Group>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={40} gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading shared documents…
|
||||
</Text>
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text size="sm" c="red">
|
||||
Could not load the shared documents.
|
||||
</Text>
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState onShare={() => setFormDoc("new")} />
|
||||
) : (
|
||||
<Stack gap={8}>
|
||||
{documents.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onView={view}
|
||||
onEdit={() => setFormDoc(doc)}
|
||||
onDelete={() => setPendingDelete(doc)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DocumentFormModal
|
||||
entityId={entityId}
|
||||
doc={formDoc === "new" ? null : formDoc}
|
||||
opened={formDoc != null}
|
||||
onClose={() => setFormDoc(null)}
|
||||
onSaved={() => {
|
||||
setFormDoc(null);
|
||||
void invalidate();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={pendingDelete != null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
title={<Text fw={700}>Remove shared document</Text>}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
|
||||
desk — and the customer, if it was shared — will no longer see it.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setPendingDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={removeMutation.isPending}
|
||||
leftSection={<Trash2 size={15} />}
|
||||
onClick={() => removeMutation.mutate(pendingDelete!.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onShare }: { onShare: () => void }) {
|
||||
return (
|
||||
<Box
|
||||
py={44}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px dashed var(--mantine-color-gray-4)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={10} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" maw={380}>
|
||||
Nothing shared yet. Anything either desk uploads here — scans,
|
||||
correspondence, corrected forms — is visible to the other side
|
||||
immediately.
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
onClick={onShare}
|
||||
>
|
||||
Share the first document
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
doc: Freight.GlExchangeDocument;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const side = SIDES[doc.side];
|
||||
const canPreview = isViewable({ name: doc.file.name, url: "" });
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
|
||||
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
|
||||
<FileText size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{doc.title}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
|
||||
{side.label}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={doc.visibleToCustomer ? "teal" : "gray"}
|
||||
radius="sm"
|
||||
tt="none"
|
||||
leftSection={
|
||||
doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />
|
||||
}
|
||||
>
|
||||
{doc.visibleToCustomer ? "Visible to customer" : "GL only"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} truncate>
|
||||
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
|
||||
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
|
||||
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.file.id, doc.file.name).then(onView)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.file.id, doc.file.name)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{doc.canEdit ? (
|
||||
<Menu position="bottom-end" radius="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
|
||||
Edit title, visibility or file
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : (
|
||||
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
|
||||
<ThemeIcon variant="subtle" color="gray" size={28}>
|
||||
<UserCheck size={15} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentFormModal({
|
||||
entityId,
|
||||
doc,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
entityId: string;
|
||||
doc: Freight.GlExchangeDocument | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = doc != null;
|
||||
const [title, setTitle] = useState("");
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
// Re-seed the form whenever a different document (or "new") opens it.
|
||||
const [seededFor, setSeededFor] = useState<string | null>(null);
|
||||
const seedKey = opened ? (doc?.id ?? "new") : null;
|
||||
if (seedKey !== seededFor) {
|
||||
setSeededFor(seedKey);
|
||||
setTitle(doc?.title ?? "");
|
||||
setVisible(doc?.visibleToCustomer ?? false);
|
||||
setFile(null);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
editing
|
||||
? glExchangeService.update(doc.id, {
|
||||
title: title.trim(),
|
||||
visibleToCustomer: visible,
|
||||
file,
|
||||
})
|
||||
: glExchangeService.upload(entityId, {
|
||||
title: title.trim(),
|
||||
visibleToCustomer: visible,
|
||||
file: file!,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(editing ? "Document updated" : "Document shared");
|
||||
onSaved();
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not save document"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Share2 size={18} />
|
||||
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Document title"
|
||||
placeholder="e.g. Corrected packing list for container TCLU1234567"
|
||||
description="What the other desk (and the customer, if shared) will see."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
maxLength={300}
|
||||
required
|
||||
/>
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={editing ? "Replacement file (optional)" : "File"}
|
||||
description={
|
||||
editing
|
||||
? "Leave empty to keep the current file."
|
||||
: "Any document type — PDF, image, spreadsheet."
|
||||
}
|
||||
accept="*/*"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode={editing}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={visible}
|
||||
onChange={(e) => setVisible(e.currentTarget.checked)}
|
||||
color="edr-green"
|
||||
label="Visible to the customer"
|
||||
description="Shows in the customer's booking documents. Off keeps it between the two GL desks."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={save.isPending}
|
||||
disabled={!title.trim() || (!editing && !file)}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{editing ? "Save changes" : "Share document"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
@@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick<
|
||||
| "nextAction"
|
||||
| "dutyRequired"
|
||||
| "dutyAdvice"
|
||||
| "dutyDispute"
|
||||
| "draftDeclaration"
|
||||
| "draftDeclarationChangeRequest"
|
||||
| "transitAssignee"
|
||||
| "roHold"
|
||||
| "roHoldReason"
|
||||
@@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick<
|
||||
| "t1Closed"
|
||||
| "t1ClosedAt"
|
||||
| "offloaded"
|
||||
| "offload"
|
||||
| "finalInvoice"
|
||||
| "vesselDepartureDate"
|
||||
| "vesselArrivalDate"
|
||||
@@ -113,32 +116,49 @@ function computeImportActiveStep(
|
||||
bookingMilestones: MilestoneRow[],
|
||||
t1Uploaded: boolean,
|
||||
freightPaid: boolean,
|
||||
isBooking: boolean,
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
if (!clearance.transitAssignee?.name) return 1;
|
||||
// Draft declaration is a booking-only step — the customer only ever reviews
|
||||
// it on the booking-scoped portal page, so it never applies (and never
|
||||
// gates) on the contract-scoped pre-booking page. Also a backward-compat
|
||||
// guard: a booking that already has a real declaration filed got there
|
||||
// before this step existed — never send it backward for a draft it was
|
||||
// never asked to send.
|
||||
if (
|
||||
isBooking &&
|
||||
!isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED")
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3;
|
||||
if (
|
||||
clearance.dutyRequired === null ||
|
||||
clearance.dutyRequired === undefined ||
|
||||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
|
||||
) {
|
||||
return 2;
|
||||
return 4;
|
||||
}
|
||||
if (
|
||||
clearance.dutyRequired &&
|
||||
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
|
||||
) {
|
||||
return 3;
|
||||
return 5;
|
||||
}
|
||||
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
|
||||
if (!clearance.preClearanceFinalized) return 5;
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6;
|
||||
if (!clearance.preClearanceFinalized) return 7;
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8;
|
||||
if (!bookingCreated) return 9;
|
||||
// The customer pays the train/freight charges on the booking. Until that
|
||||
// settles the gate pass is not granted for this booking, so the flow stops here.
|
||||
if (!freightPaid) return 8;
|
||||
if (!clearance.gatepassGranted) return 9;
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 10;
|
||||
if (!clearance.t1?.closed) return 11;
|
||||
if (!freightPaid) return 10;
|
||||
if (!clearance.gatepassGranted) return 11;
|
||||
// Step 12 is the read-only Offload step — cargo comes off the train at
|
||||
// arrival, i.e. AFTER the T1 steps below, so it never gates the flow.
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 13;
|
||||
if (!clearance.t1?.closed) return 14;
|
||||
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
||||
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||
@@ -146,15 +166,15 @@ function computeImportActiveStep(
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
if (!riskAssigned) return 12;
|
||||
if (!riskAssigned) return 15;
|
||||
// Additional duty round is optional — resolved once skipped or paid.
|
||||
const secondDutyResolved =
|
||||
clearance.secondDuty?.skipped ||
|
||||
clearance.secondDuty?.paid ||
|
||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||
if (!secondDutyResolved) return 13;
|
||||
if (!clearance.importReleaseGranted) return 14;
|
||||
return 15;
|
||||
if (!secondDutyResolved) return 16;
|
||||
if (!clearance.importReleaseGranted) return 17;
|
||||
return 18;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
@@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({
|
||||
const freightPaid =
|
||||
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
|
||||
Boolean(clearance.gatepassGranted);
|
||||
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
|
||||
const activeStep = useMemo(
|
||||
() =>
|
||||
isImport
|
||||
@@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({
|
||||
bookingMilestones,
|
||||
t1Uploaded,
|
||||
freightPaid,
|
||||
isBooking,
|
||||
)
|
||||
: 0,
|
||||
[
|
||||
@@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Request transit assignee"
|
||||
description="Ask GL Djibouti to name the officer handling this shipment"
|
||||
icon={
|
||||
clearance.transitAssignee?.name ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{showEt && canEt ? (
|
||||
<TransitAssigneePanel
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : (
|
||||
<StepStatus
|
||||
done={Boolean(clearance.transitAssignee?.name)}
|
||||
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
|
||||
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
|
||||
/>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Draft declaration"
|
||||
description="Send the customer a draft declaration with an estimated price"
|
||||
icon={
|
||||
isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<FileText size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{showEt && canEt && activeStep === 2 ? (
|
||||
<DraftDeclarationStep
|
||||
bookingId={entityId}
|
||||
clearance={clearance}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(clearance.draftDeclaration?.files ?? []).map((file, index) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={file.id}
|
||||
label={`Draft declaration document ${index + 1}`}
|
||||
file={file}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
<StepStatus
|
||||
done={isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED")}
|
||||
pendingLabel={
|
||||
clearance.draftDeclaration
|
||||
? "Waiting for the customer to accept the draft declaration."
|
||||
: "Send the customer a draft declaration to review."
|
||||
}
|
||||
doneLabel="Draft declaration accepted by the customer."
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs declaration"
|
||||
description="Upload declaration documents"
|
||||
@@ -344,24 +436,10 @@ 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 &&
|
||||
!clearance.transitAssignee?.name &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||
<TransitAssigneePanel
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : showEt &&
|
||||
canEt &&
|
||||
!clearance.bookingReady &&
|
||||
(activeStep >= 1 ||
|
||||
(activeStep >= 3 ||
|
||||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
|
||||
<DeclarationStep
|
||||
entityId={entityId}
|
||||
@@ -398,7 +476,7 @@ export function PhasedClearanceActionPanel({
|
||||
description="Advise amount and attach notice"
|
||||
icon={<Receipt size={14} />}
|
||||
>
|
||||
{showEt && canEt && activeStep === 2 ? (
|
||||
{showEt && canEt && activeStep === 4 ? (
|
||||
<DutyStep
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
@@ -460,7 +538,7 @@ export function PhasedClearanceActionPanel({
|
||||
{showEt &&
|
||||
canEt &&
|
||||
!bookingCreated &&
|
||||
(activeStep >= 4 ||
|
||||
(activeStep >= 6 ||
|
||||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
|
||||
<TransitPermitStep
|
||||
entityId={entityId}
|
||||
@@ -504,7 +582,7 @@ export function PhasedClearanceActionPanel({
|
||||
description="Hand off to GL Djibouti"
|
||||
icon={<PackageCheck size={14} />}
|
||||
>
|
||||
{showEt && canEt && activeStep === 5 ? (
|
||||
{showEt && canEt && activeStep === 7 ? (
|
||||
<FinalizePreClearanceStep
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
@@ -624,6 +702,22 @@ export function PhasedClearanceActionPanel({
|
||||
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Read-only: offload is recorded by operations when the train
|
||||
reaches the destination, which happens after the T1 steps — so
|
||||
it never holds the active pointer, and its icon stays neutral
|
||||
until it actually happens. */}
|
||||
<Stepper.Step
|
||||
label="Offload"
|
||||
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
|
||||
color={offloadDone ? undefined : "gray"}
|
||||
icon={<PackageOpen size={14} />}
|
||||
completedIcon={
|
||||
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
|
||||
}
|
||||
>
|
||||
<OffloadStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after the gate pass is secured"
|
||||
@@ -1049,6 +1143,91 @@ function ImportGatepassStep({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact offload line for the step's description row — the only part of a
|
||||
* Mantine step that stays visible once the flow has moved past it.
|
||||
*/
|
||||
export function offloadSummary(
|
||||
offload: ClearanceViewLike["offload"],
|
||||
offloaded: boolean,
|
||||
): string {
|
||||
if (!offload?.offloaded && !offloaded) {
|
||||
return "Cargo comes off the train at its destination";
|
||||
}
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
const bits = [
|
||||
offload?.containers ? plural(offload.containers, "container") : null,
|
||||
offload?.wagons ? plural(offload.wagons, "wagon") : null,
|
||||
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null,
|
||||
offload?.destination ?? null,
|
||||
].filter(Boolean);
|
||||
return bits.length ? bits.join(" · ") : "Offloaded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Offload stats for the booking, read-only. Recorded by the warehouse
|
||||
* auto-unload that runs when the train reaches the booking's destination —
|
||||
* nothing here is actioned from clearance.
|
||||
*/
|
||||
export function OffloadStep({
|
||||
clearance,
|
||||
}: {
|
||||
clearance: ClearanceViewLike;
|
||||
}) {
|
||||
const offload = clearance.offload ?? null;
|
||||
const done = offload?.offloaded ?? Boolean(clearance.offloaded);
|
||||
|
||||
if (!done) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for the cargo to be offloaded at its destination (recorded by operations on arrival)."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const stats: Array<[string, string]> = [
|
||||
["Containers", offload?.containers ? String(offload.containers) : "—"],
|
||||
["Wagons", offload?.wagons ? String(offload.wagons) : "—"],
|
||||
[
|
||||
"Weight",
|
||||
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—",
|
||||
],
|
||||
["Destination", offload?.destination ?? "—"],
|
||||
["GRN", offload?.grnNumber ?? "—"],
|
||||
["Location", offload?.location ?? "—"],
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm" bg="var(--mantine-color-edr-green-0)">
|
||||
<Group gap="xs" wrap="nowrap" mb="xs">
|
||||
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
|
||||
Offloaded
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{offload?.offloadedAt
|
||||
? new Date(offload.offloadedAt).toLocaleString()
|
||||
: "Recorded on arrival"}
|
||||
{offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
{stats.map(([label, value]) => (
|
||||
<Stack key={label} gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const RISK_LEVEL_COLOR: Record<string, string> = {
|
||||
GREEN: "green",
|
||||
YELLOW: "yellow",
|
||||
@@ -1569,6 +1748,145 @@ export function DeclarationStep({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
|
||||
* the customer to review in the portal before the real declaration is filed.
|
||||
* Booking-only — the customer only ever sees this on the booking-scoped page.
|
||||
*/
|
||||
function DraftDeclarationStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
bookingId: string;
|
||||
clearance: ClearanceViewLike;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [price, setPrice] = useState<number | string>(
|
||||
clearance.draftDeclaration?.price ?? "",
|
||||
);
|
||||
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changeRequest = clearance.draftDeclarationChangeRequest;
|
||||
const existingFiles = clearance.draftDeclaration?.files ?? [];
|
||||
const replaceMode = existingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* The customer sent this draft back — their words drive the
|
||||
correction, so they lead the step. */}
|
||||
{changeRequest ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={16} />}
|
||||
title={
|
||||
changeRequest.rounds > 1
|
||||
? `Customer requested a change (round ${changeRequest.rounds})`
|
||||
: "Customer requested a change"
|
||||
}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{changeRequest.note}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Raised {new Date(changeRequest.raisedAt).toLocaleString()} — send a
|
||||
corrected draft below.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{existingFiles.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
Current draft
|
||||
</Text>
|
||||
{existingFiles.map((file, index) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={file.id}
|
||||
label={`Draft declaration document ${index + 1}`}
|
||||
file={file}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label="Estimated price"
|
||||
placeholder="0.00"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
min={0}
|
||||
size="sm"
|
||||
thousandSeparator=","
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<PhasedMultiFileDropzone
|
||||
label="Draft declaration documents"
|
||||
description={
|
||||
replaceMode
|
||||
? "Replace the draft — upload one or more corrected documents."
|
||||
: "Upload one or more draft declaration documents (PDF or image)."
|
||||
}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
replaceMode={replaceMode}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={files.length === 0 || price === ""}
|
||||
leftSection={<Upload size={16} />}
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await bookingsService.uploadDraftDeclaration(
|
||||
bookingId,
|
||||
files,
|
||||
Number(price),
|
||||
currency,
|
||||
);
|
||||
setFiles([]);
|
||||
toast.success(replaceMode ? "Corrected draft sent" : "Draft sent to customer");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{replaceMode ? "Send corrected draft" : "Send draft to customer"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DutyStep({
|
||||
entityId,
|
||||
isBooking,
|
||||
@@ -1597,35 +1915,9 @@ 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">
|
||||
|
||||
@@ -5,18 +5,19 @@ import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { transitAgentsService } from "@/services/transit-agents.service";
|
||||
|
||||
export interface TransitAssigneePanelProps {
|
||||
/** Booking id when `isBooking`, contract id otherwise. */
|
||||
@@ -62,10 +63,20 @@ export function TransitAssigneePanel({
|
||||
onChanged,
|
||||
}: TransitAssigneePanelProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
|
||||
const [changing, setChanging] = useState(false);
|
||||
const service = isBooking ? bookingsService : contractsService;
|
||||
|
||||
const { data: assignableAgents, isLoading: loadingAgents } = useQuery({
|
||||
queryKey: ["transit-agents", "assignable"],
|
||||
queryFn: () => transitAgentsService.listAssignable(),
|
||||
enabled: side === "DJ",
|
||||
});
|
||||
const agentOptions = (assignableAgents ?? []).map((a) => ({
|
||||
value: a.id,
|
||||
label: a.name,
|
||||
}));
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: async () => {
|
||||
await service.requestTransitAssignee(entityId, note.trim());
|
||||
@@ -79,7 +90,8 @@ export function TransitAssigneePanel({
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: async () => {
|
||||
await service.assignTransitAssignee(entityId, assignee.trim());
|
||||
if (!transitAgentId) return;
|
||||
await service.assignTransitAssignee(entityId, transitAgentId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Transit assignee sent to GL Ethiopia");
|
||||
@@ -114,7 +126,7 @@ export function TransitAssigneePanel({
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setAssignee(transitAssignee!.name ?? "");
|
||||
setTransitAgentId(null);
|
||||
setChanging(true);
|
||||
}}
|
||||
>
|
||||
@@ -152,13 +164,16 @@ export function TransitAssigneePanel({
|
||||
GL Ethiopia: {transitAssignee.requestNote}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
<Select
|
||||
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}
|
||||
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
|
||||
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
|
||||
data={agentOptions}
|
||||
value={transitAgentId}
|
||||
onChange={setTransitAgentId}
|
||||
searchable
|
||||
disabled={readOnly || loadingAgents}
|
||||
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{changing ? (
|
||||
@@ -171,7 +186,7 @@ export function TransitAssigneePanel({
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
loading={assign.isPending}
|
||||
disabled={readOnly || !assignee.trim()}
|
||||
disabled={readOnly || !transitAgentId}
|
||||
onClick={() => assign.mutate()}
|
||||
>
|
||||
Send assignment
|
||||
|
||||
@@ -66,6 +66,19 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "validityBadge") {
|
||||
const status = String(value);
|
||||
const label =
|
||||
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
|
||||
const color =
|
||||
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
|
||||
return (
|
||||
<Badge color={color} variant="filled" size="sm" radius="md">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "code") {
|
||||
return (
|
||||
<Badge
|
||||
|
||||
@@ -34,12 +34,13 @@ import type {
|
||||
} from "@/types/trainScheduling";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
import { ForecastPanel } from "./ForecastPanel";
|
||||
import { forecastIsLive } from "./batchForecast";
|
||||
import { forecastIsLive, rankBookings } from "./batchForecast";
|
||||
|
||||
/**
|
||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||
* schedule in the exact order the batch engine boards them (government first,
|
||||
* then rule-engine priority score, then oldest). Bookings above the train's
|
||||
* then window cycle — bookings compete only within their own cycle — then
|
||||
* rule-engine priority score, then oldest). Bookings above the train's
|
||||
* wagon-capacity line render as "selected" (green), below it as the waiting
|
||||
* list; during the PAYMENT phase selected bookings show a live pay-window
|
||||
* countdown. Purely presentational — data comes from the batch-board detail
|
||||
@@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||
);
|
||||
const showForecast = forecastAvailable && view === "forecast";
|
||||
|
||||
// Rank exactly as the batch engine does: government first, then priority score
|
||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||
// backend uses). The board already returns them in this order, but re-sort
|
||||
// defensively so the tab is correct even if the source order ever changes.
|
||||
const ranked = useMemo(() => {
|
||||
const time = (b: BatchBoardBookingDetail) =>
|
||||
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
});
|
||||
}, [bookings]);
|
||||
// Rank exactly as the batch engine does: government first, then window cycle
|
||||
// (bookings only compete within the cycle they arrived in — an earlier cycle
|
||||
// boards before a later one regardless of score), then priority desc, then
|
||||
// oldest. Shared with the forecast sim so both views agree.
|
||||
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
|
||||
|
||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||
// Wagon-slot cap from the board DTO (derived from train length and the
|
||||
@@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Priority ranking</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Government first, then rule-engine score, then earliest booked.
|
||||
Government first, then booking window (earlier cycles board
|
||||
first), then rule-engine score, then earliest booked.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -61,7 +61,12 @@ export interface ForecastResult {
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Engine rank order: government first, then priority desc, then oldest booked. */
|
||||
/**
|
||||
* Engine rank order: government first, then window cycle asc (bookings compete
|
||||
* only within the cycle they arrived in — earlier cycles board first no matter
|
||||
* the score; pending-contract rows sink last), then priority desc, then oldest
|
||||
* booked.
|
||||
*/
|
||||
export function rankBookings(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
): BatchBoardBookingDetail[] {
|
||||
@@ -69,8 +74,11 @@ export function rankBookings(
|
||||
b.fullyExecutedAt
|
||||
? new Date(b.fullyExecutedAt).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const cycle = (b: BatchBoardBookingDetail) =>
|
||||
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b);
|
||||
if (b.priorityScore !== a.priorityScore)
|
||||
return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
|
||||
@@ -142,7 +142,7 @@ export const TrainConsistView = ({
|
||||
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||
lengthUsed={lengthUsed}
|
||||
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||
wagonCount={wagons.length}
|
||||
wagonCount={loadedCount}
|
||||
wagonMax={maxWagons}
|
||||
/>
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_DECLARATION: (id: string) =>
|
||||
`/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
|
||||
`/bookings/${id}/clearance/draft-declaration`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-assignee/request`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||
@@ -166,6 +168,13 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
|
||||
// GL Ethiopia ↔ GL Djibouti document exchange, keyed by the booking or
|
||||
// contract both desks are working on.
|
||||
GL_EXCHANGE: {
|
||||
FOR_ENTITY: (entityId: string) => `/gl-exchange/${entityId}`,
|
||||
DOCUMENT: (documentId: string) => `/gl-exchange/documents/${documentId}`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
BASE: "/contracts",
|
||||
LIST_SUMMARY: "/contracts/list-summary",
|
||||
@@ -467,6 +476,10 @@ export const URL_CONSTANTS = {
|
||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
|
||||
|
||||
TRANSIT_AGENTS: "/transit-agents",
|
||||
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
|
||||
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
|
||||
},
|
||||
RATE_MATRIX: {
|
||||
BASE: "/api/rate-matrices",
|
||||
|
||||
@@ -270,6 +270,41 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Statuses where the next action sits with the customer (portal side). */
|
||||
const WITH_CUSTOMER_STATUSES = new Set([
|
||||
"DRAFT",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
"CONTRACT_READY", // generated contract awaits the customer's signature
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"RENEWAL_DRAFT",
|
||||
]);
|
||||
|
||||
/** Statuses where the next action sits with EDR staff. */
|
||||
const WITH_EDR_STATUSES = new Set([
|
||||
"SUBMITTED",
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"SIGNED_CUSTOMER",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"RENEWAL_SUBMITTED",
|
||||
"RENEWAL_PENDING_APPROVAL",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whose court the contract is in. Null for states with no pending party
|
||||
* (active, closed, rejected…).
|
||||
*/
|
||||
export function contractCourt(
|
||||
status: ContractStatus | string,
|
||||
): "customer" | "edr" | null {
|
||||
if (WITH_CUSTOMER_STATUSES.has(status)) return "customer";
|
||||
if (WITH_EDR_STATUSES.has(status)) return "edr";
|
||||
return null;
|
||||
}
|
||||
|
||||
export const CONTRACT_LIST_TABS = [
|
||||
{ key: "all", label: "All contracts", statuses: null as string[] | null },
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() {
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
: requestedTab === "trucks"
|
||||
? "trucks"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() {
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||
Trucks
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="trucks">
|
||||
<BookingTrucksPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(id)}
|
||||
showWorkflowFilesTab={isPhasedGeneral}
|
||||
exchangeEntityId={id}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
||||
@@ -16,6 +17,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
|
||||
{ value: "CLEARANCE_READY", label: "Clearance ready" },
|
||||
];
|
||||
|
||||
const TRADE_DIRECTION_OPTIONS = [
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "DOMESTIC", label: "Domestic" },
|
||||
];
|
||||
|
||||
const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ value: "true", label: "Government" },
|
||||
{ value: "false", label: "Private" },
|
||||
];
|
||||
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
BOOKING_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
|
||||
|
||||
const search = debouncedQuery.trim() || undefined;
|
||||
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
|
||||
const page = pagination.pageIndex + 1;
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"bookings",
|
||||
bookingStatuses,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
ownershipFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
page,
|
||||
search,
|
||||
],
|
||||
queryFn: () =>
|
||||
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
|
||||
{
|
||||
id: "contractRef",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
return b.contractId && b.contractReference ? (
|
||||
<ContractReferenceLink
|
||||
contractId={b.contractId}
|
||||
contractReference={b.contractReference}
|
||||
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm">—</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shipment",
|
||||
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 130 }}
|
||||
aria-label="Filter by direction"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Freight type"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by ownership"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created from"
|
||||
value={createdFrom}
|
||||
onChange={(v) => {
|
||||
setCreatedFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={createdTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Created from"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created to"
|
||||
value={createdTo}
|
||||
onChange={(v) => {
|
||||
setCreatedTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={createdFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Created to"
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
|
||||
@@ -352,6 +352,7 @@ export default function ContractClearanceDetailPage() {
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(linkedBookingId)}
|
||||
showWorkflowFilesTab={phasedCustoms}
|
||||
exchangeEntityId={id}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
Files,
|
||||
Flame,
|
||||
History,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractCourtBadge,
|
||||
ContractStatusBadge,
|
||||
} from "@/components/contracts/ContractStatusBadge";
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<ContractCourtBadge status={contract.status} />
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Info}
|
||||
title="Contract information"
|
||||
subtitle="Full commercial and operational detail for this contract."
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<InfoRow
|
||||
label="Service type"
|
||||
value={contract.serviceType?.serviceName ?? "—"}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Payment currency"
|
||||
value={contract.paymentCurrency ?? "—"}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Customs clearing"
|
||||
value={
|
||||
contract.customsClearingEnabled
|
||||
? "Included automatically"
|
||||
: contract.customsClearingAgent
|
||||
? `Customer's agent — ${contract.customsClearingAgent}`
|
||||
: "Not included"
|
||||
}
|
||||
/>
|
||||
{contract.equipmentReturn ? (
|
||||
<InfoRow
|
||||
label="Equipment return"
|
||||
value={
|
||||
contract.equipmentReturn === "WITH_RETURN"
|
||||
? "With return"
|
||||
: "Without return"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow
|
||||
label="Contract type"
|
||||
value={contract.contractType ?? "Standard"}
|
||||
/>
|
||||
{contract.contractValidityDays != null ? (
|
||||
<InfoRow
|
||||
label="Validity period"
|
||||
value={`${contract.contractValidityDays} days`}
|
||||
/>
|
||||
) : null}
|
||||
{contract.estimatedShipmentDate ? (
|
||||
<InfoRow
|
||||
label="Estimated shipment date"
|
||||
value={formatDate(contract.estimatedShipmentDate)}
|
||||
/>
|
||||
) : null}
|
||||
{contract.firstMilePickupAddress ? (
|
||||
<InfoRow
|
||||
label="First-mile pickup"
|
||||
value={contract.firstMilePickupAddress}
|
||||
/>
|
||||
) : null}
|
||||
{contract.lastMileDeliveryAddress ? (
|
||||
<InfoRow
|
||||
label="Last-mile delivery"
|
||||
value={contract.lastMileDeliveryAddress}
|
||||
/>
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
{contract.financialTerms ? (
|
||||
<Box
|
||||
mt="md"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
mb={4}
|
||||
style={{ letterSpacing: 0.3 }}
|
||||
>
|
||||
Financial terms
|
||||
</Text>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{contract.financialTerms}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval & signing timeline"
|
||||
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
|
||||
>
|
||||
<ContractMilestonesTimeline contract={contract} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -761,6 +862,25 @@ export default function ContractRequestDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.3 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} mt={2}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
|
||||
@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractCourtBadge,
|
||||
ContractStatusBadge,
|
||||
} from "@/components/contracts/ContractStatusBadge";
|
||||
import {
|
||||
ContractStatusTabs,
|
||||
type ContractStatusTabKey,
|
||||
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "court",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Waiting on</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractCourtBadge status={row.original.status} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
size: COLUMN_WIDTH,
|
||||
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
|
||||
}}
|
||||
// table-fixed makes the per-column 120px widths stick; without
|
||||
// it auto-layout re-widens columns once cells wrap.
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AlertTriangle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Share2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -33,6 +34,7 @@ import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||
import {
|
||||
GlClearanceUploadModal,
|
||||
type GlClearanceUploadKind,
|
||||
@@ -228,6 +230,9 @@ export default function GlClearanceDetailPage() {
|
||||
>
|
||||
Customs documents (all steps)
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
|
||||
Document exchange
|
||||
</Tabs.Tab>
|
||||
{incidentBookingId ? (
|
||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||
Incidents
|
||||
@@ -237,23 +242,20 @@ export default function GlClearanceDetailPage() {
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
{/* GL Ethiopia cannot file the import customs declaration until this
|
||||
desk names the officer handling the shipment in transit, so the
|
||||
ask sits above everything else on the page. Exports have no such
|
||||
gate — Djibouti's steps come after the declaration. */}
|
||||
{isImport ? (
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
desk names the officer handling the shipment in transit. Exports also
|
||||
need transit assignment at the DJ stage after ET requests it. */}
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
@@ -342,6 +344,10 @@ export default function GlClearanceDetailPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="exchange">
|
||||
<GlExchangePanel entityId={id!} />
|
||||
</Tabs.Panel>
|
||||
|
||||
{incidentBookingId ? (
|
||||
<Tabs.Panel value="incidents">
|
||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||
|
||||
@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "card", label: "Card" },
|
||||
{ value: "dmoney", label: "D-Money" },
|
||||
{ value: "cac-bank", label: "CAC Bank" },
|
||||
{ value: "cbe-bill", label: "CBE Bill" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ColumnFormat =
|
||||
| "boolean"
|
||||
| "activeBadge"
|
||||
| "rateStatus"
|
||||
| "validityBadge"
|
||||
| "date"
|
||||
| "number"
|
||||
| "currency"
|
||||
@@ -483,6 +484,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "transit-agents",
|
||||
label: "Transit Agents",
|
||||
category: "configuration",
|
||||
subtitle:
|
||||
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
|
||||
searchPlaceholder: "Search transit agents by name...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
|
||||
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
|
||||
{
|
||||
id: "validityStatus",
|
||||
header: "Validity",
|
||||
accessorKey: "validityStatus",
|
||||
format: "validityBadge",
|
||||
},
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{ name: "validFrom", label: "Valid from", type: "date", required: true },
|
||||
{
|
||||
name: "validTo",
|
||||
label: "Valid to",
|
||||
type: "date",
|
||||
required: true,
|
||||
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "yard-distances",
|
||||
label: "Yard Distances",
|
||||
|
||||
@@ -338,10 +338,10 @@ export const bookingsService = {
|
||||
note,
|
||||
}),
|
||||
|
||||
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, assignee: string) =>
|
||||
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, transitAgentId: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
||||
assignee,
|
||||
transitAgentId,
|
||||
}),
|
||||
|
||||
uploadDeclaration: async (
|
||||
@@ -382,6 +382,22 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
uploadDraftDeclaration: async (
|
||||
id: string,
|
||||
files: File[],
|
||||
price: number,
|
||||
currency: string,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
|
||||
form.append("price", String(price));
|
||||
form.append("currency", currency);
|
||||
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
finalizePreClearance: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||
|
||||
|
||||
@@ -331,10 +331,10 @@ export const contractsService = {
|
||||
{ note },
|
||||
),
|
||||
|
||||
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, assignee: string) =>
|
||||
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, transitAgentId: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
||||
assignee,
|
||||
transitAgentId,
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const X = URL_CONSTANTS.GL_EXCHANGE;
|
||||
|
||||
export interface GlExchangeUpload {
|
||||
title: string;
|
||||
visibleToCustomer: boolean;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface GlExchangeEdit {
|
||||
title?: string;
|
||||
visibleToCustomer?: boolean;
|
||||
/** Optional replacement bytes — omit to keep the stored file. */
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
const multipart = { headers: { "Content-Type": "multipart/form-data" } };
|
||||
|
||||
/** GL Ethiopia ↔ GL Djibouti shared documents for one booking or contract. */
|
||||
export const glExchangeService = {
|
||||
list: async (entityId: string): Promise<Freight.GlExchangeDocument[]> => {
|
||||
const response = await client.get(X.FOR_ENTITY(entityId));
|
||||
return (unwrap(response.data) ?? []) as Freight.GlExchangeDocument[];
|
||||
},
|
||||
|
||||
upload: async (
|
||||
entityId: string,
|
||||
input: GlExchangeUpload,
|
||||
): Promise<Freight.GlExchangeDocument> => {
|
||||
const form = new FormData();
|
||||
form.append("file", input.file);
|
||||
form.append("title", input.title);
|
||||
form.append("visibleToCustomer", String(input.visibleToCustomer));
|
||||
const response = await client.post(X.FOR_ENTITY(entityId), form, multipart);
|
||||
return unwrap(response.data) as Freight.GlExchangeDocument;
|
||||
},
|
||||
|
||||
update: async (
|
||||
documentId: string,
|
||||
input: GlExchangeEdit,
|
||||
): Promise<Freight.GlExchangeDocument> => {
|
||||
const form = new FormData();
|
||||
if (input.file) form.append("file", input.file);
|
||||
if (input.title != null) form.append("title", input.title);
|
||||
if (input.visibleToCustomer != null) {
|
||||
form.append("visibleToCustomer", String(input.visibleToCustomer));
|
||||
}
|
||||
const response = await client.patch(X.DOCUMENT(documentId), form, multipart);
|
||||
return unwrap(response.data) as Freight.GlExchangeDocument;
|
||||
},
|
||||
|
||||
remove: async (documentId: string): Promise<void> => {
|
||||
await client.delete(X.DOCUMENT(documentId));
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
| "cac-bank"
|
||||
| "cbe-bill";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
|
||||
@@ -94,6 +94,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
||||
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
|
||||
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
|
||||
"transit-agents": URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS,
|
||||
};
|
||||
|
||||
const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { api } from "../auth/http";
|
||||
import { URL_CONSTANTS } from "../constants/URLS";
|
||||
|
||||
export interface TransitAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const transitAgentsService = {
|
||||
/** Active + currently inside its validity window — the assignment dropdown. */
|
||||
async listAssignable() {
|
||||
const response = await api.get<TransitAgent[]>(
|
||||
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export interface ContainerItem {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -136,6 +137,8 @@ const cleanParams = (params: object) =>
|
||||
|
||||
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
|
||||
export interface LastMileArrivalTruck {
|
||||
/** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
|
||||
@@ -10,7 +10,8 @@ export type RuleEngineResourceSlug =
|
||||
| "yard-distances"
|
||||
| "shipping-lines"
|
||||
| "rates"
|
||||
| "approval-rules";
|
||||
| "approval-rules"
|
||||
| "transit-agents";
|
||||
|
||||
/**
|
||||
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are
|
||||
|
||||
@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
/**
|
||||
* 0-based booking-window cycle the booking entered the pool in. Ranking is
|
||||
* per-cycle: an earlier cycle always boards before a later one regardless of
|
||||
* priority score. Null while the contract is still pending.
|
||||
*/
|
||||
windowCycleNo: number | null;
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
|
||||
@@ -168,6 +168,8 @@ export const URL_CONSTANTS = {
|
||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||
BOOKING_FINAL_INVOICE_APPROVE: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/final-invoice/approve`,
|
||||
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
|
||||
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -72,6 +73,12 @@ export default function InvoiceDetailPage() {
|
||||
} = useQuery(api.invoices.get.queryOptions({ input: { id } }));
|
||||
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
// CBE bill payment: the bill reference to pay at any CBE channel (no redirect).
|
||||
const [billAction, setBillAction] = useState<{
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
|
||||
// one of the signed-in customer's own invoices (unlike the admin-facing
|
||||
@@ -80,6 +87,11 @@ export default function InvoiceDetailPage() {
|
||||
mutationFn: (method: PaymentMethod) =>
|
||||
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
|
||||
onSuccess: (data, method) => {
|
||||
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
|
||||
setPayModalOpen(false);
|
||||
setBillAction(data.clientAction);
|
||||
return;
|
||||
}
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
@@ -393,6 +405,62 @@ export default function InvoiceDetailPage() {
|
||||
}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
/>
|
||||
|
||||
{/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */}
|
||||
<Modal
|
||||
opened={!!billAction}
|
||||
onClose={() => setBillAction(null)}
|
||||
centered
|
||||
radius={18}
|
||||
size={440}
|
||||
title={<Text fw={800}>Pay at CBE</Text>}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c={MUTED}>
|
||||
{billAction?.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Text ff="monospace" fz={24} fw={800} c={INK} style={{ letterSpacing: 3 }}>
|
||||
{billAction?.billReference}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (billAction?.billReference) {
|
||||
navigator.clipboard?.writeText(billAction.billReference);
|
||||
toast.success("Bill number copied");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Amount due:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{formatCurrency(amountDue, invoice.currency)}
|
||||
</Text>
|
||||
</Text>
|
||||
{billAction?.expiresAt && (
|
||||
<Text fz="sm" c={MUTED}>
|
||||
Pay before:{" "}
|
||||
<Text span fw={700} c={INK}>
|
||||
{fmtDate(billAction.expiresAt)}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text fz="xs" c={MUTED}>
|
||||
The invoice updates automatically once CBE confirms your payment.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
FileBadge,
|
||||
@@ -86,9 +87,11 @@ export function BookingClearanceWorkflowBanner({
|
||||
clearance.dutyRequired &&
|
||||
clearance.dutyAdvice &&
|
||||
!dutyPaid;
|
||||
// A dispute clears the advice while it's open — show the "waiting on GL"
|
||||
// state instead of the pay panel until GL re-advises.
|
||||
const dutyDisputePending = Boolean(clearance.dutyDispute);
|
||||
// A change request clears the draft while it's open — show the "waiting on
|
||||
// GL" state instead of the review panel until GL sends a corrected draft.
|
||||
const draftDeclarationChangeRequestPending = Boolean(
|
||||
clearance.draftDeclarationChangeRequest,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
@@ -114,9 +117,20 @@ export function BookingClearanceWorkflowBanner({
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{dutyDisputePending && clearance.dutyDispute ? (
|
||||
<DutyDisputePendingCard dispute={clearance.dutyDispute} />
|
||||
) : dutyPending && clearance.dutyAdvice ? (
|
||||
{draftDeclarationChangeRequestPending && clearance.draftDeclarationChangeRequest ? (
|
||||
<DraftDeclarationChangeRequestedCard
|
||||
changeRequest={clearance.draftDeclarationChangeRequest}
|
||||
/>
|
||||
) : clearance.draftDeclaration && !clearance.draftDeclaration.accepted ? (
|
||||
<DraftDeclarationPanel
|
||||
draftDeclaration={clearance.draftDeclaration}
|
||||
bookingId={booking.id}
|
||||
onView={(f) => view(f)}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dutyPending && clearance.dutyAdvice ? (
|
||||
<DutyAdvicePanel
|
||||
dutyAdvice={clearance.dutyAdvice}
|
||||
bookingId={booking.id}
|
||||
@@ -193,9 +207,6 @@ function DutyAdvicePanel({
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disputing, setDisputing] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [submittingDispute, setSubmittingDispute] = useState(false);
|
||||
const noticeFile = dutyAdvice.noticeFile;
|
||||
|
||||
return (
|
||||
@@ -250,11 +261,68 @@ function DutyAdvicePanel({
|
||||
Submit payment slip
|
||||
</Button>
|
||||
|
||||
{disputing ? (
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sent a draft customs declaration — an estimated price + files
|
||||
* the customer must accept before the real declaration is filed, or send back
|
||||
* with a note asking for a change.
|
||||
*/
|
||||
function DraftDeclarationPanel({
|
||||
draftDeclaration,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
draftDeclaration: NonNullable<Freight.ClearanceView["draftDeclaration"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [requestingChange, setRequestingChange] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||
<Stack gap="sm">
|
||||
<GroupLabel icon={FileBadge} text="Draft customs declaration" />
|
||||
<Text size="sm">
|
||||
Estimated price:{" "}
|
||||
<strong>
|
||||
{draftDeclaration.price.toLocaleString()} {draftDeclaration.currency}
|
||||
</strong>
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{draftDeclaration.files.map((file, index) => (
|
||||
<Anchor
|
||||
key={file.id}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => onView({ name: file.name, url: file.url })}
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Eye size={14} />
|
||||
Draft declaration document {index + 1} ({file.name})
|
||||
</Group>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Review the draft above. Accept it to let GL Ethiopia proceed with the
|
||||
real customs declaration, or request a change if something is wrong.
|
||||
</Text>
|
||||
|
||||
{requestingChange ? (
|
||||
<Stack gap={6}>
|
||||
<Textarea
|
||||
label="What's wrong with this amount?"
|
||||
placeholder="Explain why you're disputing the advised duty/tax…"
|
||||
label="What needs to change?"
|
||||
placeholder="Explain what should be corrected on the draft…"
|
||||
minRows={2}
|
||||
autosize
|
||||
value={note}
|
||||
@@ -265,7 +333,7 @@ function DutyAdvicePanel({
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setDisputing(false);
|
||||
setRequestingChange(false);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
@@ -274,23 +342,23 @@ function DutyAdvicePanel({
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
loading={submittingDispute}
|
||||
loading={submitting}
|
||||
disabled={!note.trim()}
|
||||
onClick={async () => {
|
||||
setSubmittingDispute(true);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await bookingsService.disputeBookingClearanceDuty(
|
||||
await bookingsService.requestDraftDeclarationChange(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
);
|
||||
toast.success("Sent to GL Ethiopia for review");
|
||||
setDisputing(false);
|
||||
setRequestingChange(false);
|
||||
setNote("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to send");
|
||||
} finally {
|
||||
setSubmittingDispute(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -299,26 +367,48 @@ function DutyAdvicePanel({
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => setDisputing(true)}
|
||||
>
|
||||
Not right? Request a change
|
||||
</Anchor>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="sm"
|
||||
loading={accepting}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setAccepting(true);
|
||||
try {
|
||||
await bookingsService.acceptDraftDeclaration(bookingId);
|
||||
toast.success("Draft accepted");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to accept");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept draft
|
||||
</Button>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
onClick={() => setRequestingChange(true)}
|
||||
>
|
||||
Not right? Request a change
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** The customer's dispute is open — GL Ethiopia owes a corrected advice. */
|
||||
function DutyDisputePendingCard({
|
||||
dispute,
|
||||
/** The customer's change request is open — GL Ethiopia owes a corrected draft. */
|
||||
function DraftDeclarationChangeRequestedCard({
|
||||
changeRequest,
|
||||
}: {
|
||||
dispute: NonNullable<Freight.ClearanceView["dutyDispute"]>;
|
||||
changeRequest: NonNullable<Freight.ClearanceView["draftDeclarationChangeRequest"]>;
|
||||
}) {
|
||||
return (
|
||||
<Alert
|
||||
@@ -326,18 +416,18 @@ function DutyDisputePendingCard({
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={16} />}
|
||||
title={
|
||||
dispute.rounds > 1
|
||||
? `Waiting on GL Ethiopia (round ${dispute.rounds})`
|
||||
changeRequest.rounds > 1
|
||||
? `Waiting on GL Ethiopia (round ${changeRequest.rounds})`
|
||||
: "Waiting on GL Ethiopia"
|
||||
}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{dispute.note}
|
||||
{changeRequest.note}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Sent {new Date(dispute.raisedAt).toLocaleString()} — you'll see the
|
||||
corrected amount here once GL Ethiopia re-advises.
|
||||
Sent {new Date(changeRequest.raisedAt).toLocaleString()} — you'll see
|
||||
the corrected draft here once GL Ethiopia sends it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
@@ -373,7 +463,11 @@ function FinalInvoiceDueCard({
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const paid = invoice.status === "PAID";
|
||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
||||
// reviews the attached invoice and approves it.
|
||||
const approved = Boolean(invoice.approvedAt);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -402,11 +496,17 @@ function FinalInvoiceDueCard({
|
||||
<Group gap={8} align="center">
|
||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
{paid ? "Final invoice paid" : "Final invoice due"} —{" "}
|
||||
{invoice.invoiceNumber}
|
||||
{paid
|
||||
? "Final invoice paid"
|
||||
: approved
|
||||
? "Final invoice due"
|
||||
: "Final invoice — your approval needed"}{" "}
|
||||
— {invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{invoiceStatusLabel(invoice.status)}
|
||||
{approved
|
||||
? invoiceStatusLabel(invoice.status)
|
||||
: "Awaiting your approval"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
@@ -419,8 +519,9 @@ function FinalInvoiceDueCard({
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
Pay the amount above and attach your payment slip — Global
|
||||
Logistics will confirm the payment.
|
||||
{approved
|
||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -458,7 +559,30 @@ function FinalInvoiceDueCard({
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
{!paid && !approved ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={approving}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await contractsService.approveFinalInvoice(bookingId);
|
||||
toast.success("Invoice approved — you can now pay");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Approve invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && approved ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={
|
||||
|
||||
@@ -393,6 +393,31 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 1b. Documents Global Logistics shared with you ──────────────── */}
|
||||
{(clearance?.exchangeDocuments?.length ?? 0) > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Shared by Global Logistics</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Documents the clearance team shared with you for this shipment.
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
{clearance!.exchangeDocuments!.map((doc, i, arr) => (
|
||||
<FileRow
|
||||
key={doc.id}
|
||||
title={doc.title}
|
||||
meta={`${doc.file.name} · shared ${new Date(
|
||||
doc.uploadedAt,
|
||||
).toLocaleDateString()}`}
|
||||
file={doc.file}
|
||||
pill={<Pill tone="green" label="Shared with you" />}
|
||||
last={i === arr.length - 1}
|
||||
onView={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 2. Contract & company documents ─────────────────────────────── */}
|
||||
{hasContract && contract && (
|
||||
<SectionCard>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ProviderOption {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
// Only Telebirr and Waafi are enabled for now.
|
||||
// Only Telebirr, Waafi and CBE bill payment are enabled for now.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "TELEBIRR",
|
||||
@@ -32,6 +32,14 @@ const PROVIDERS: ProviderOption[] = [
|
||||
currencies: ["USD"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
{
|
||||
method: "CBE_BILL",
|
||||
label: "CBE bill payment",
|
||||
description: "Pay at any CBE branch, app or USSD · ETB",
|
||||
logo: "/assets/edr-logo.png",
|
||||
currencies: ["ETB"],
|
||||
accent: "#5B2D8C",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -422,11 +422,13 @@ export function Step8Review({
|
||||
label="Customs clearing"
|
||||
value={customsValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Hazardous cargo"
|
||||
value={
|
||||
values.isHazardous ? (
|
||||
{/* Step-3 toggles appear only when the customer selected them —
|
||||
an off toggle is left off the summary entirely. */}
|
||||
{values.isHazardous && (
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Hazardous cargo"
|
||||
value={
|
||||
<>
|
||||
Yes
|
||||
<Group gap={6} mt={6}>
|
||||
@@ -438,27 +440,24 @@ export function Step8Review({
|
||||
</Badge>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
"No"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Refrigerated"
|
||||
value={values.isRefrigerated ? "Yes" : "No"}
|
||||
/>
|
||||
{values.cargoType === "container" && (
|
||||
<SummaryItem
|
||||
icon={<RotateCcw size={18} />}
|
||||
label="Empty-container return"
|
||||
value={
|
||||
values.equipmentReturn === "with_return"
|
||||
? "With return"
|
||||
: "Without return"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{values.isRefrigerated && (
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Refrigerated"
|
||||
value="Yes"
|
||||
/>
|
||||
)}
|
||||
{values.cargoType === "container" &&
|
||||
values.equipmentReturn === "with_return" && (
|
||||
<SummaryItem
|
||||
icon={<RotateCcw size={18} />}
|
||||
label="Empty-container return"
|
||||
value="With return"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -398,13 +398,21 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
disputeBookingClearanceDuty: async (
|
||||
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/draft-declaration/accept`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
requestDraftDeclarationChange: async (
|
||||
id: string,
|
||||
note: string,
|
||||
): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(`/api/bookings/${id}/clearance/duty/dispute`, {
|
||||
note,
|
||||
});
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/draft-declaration/change`,
|
||||
{ note },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
|
||||
@@ -439,6 +439,14 @@ export const contractsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Customer approves the drafted GL final invoice — unlocks the slip upload. */
|
||||
approveFinalInvoice: async (bookingId: string): Promise<unknown> => {
|
||||
const { data } = await client.post(
|
||||
C.BOOKING_FINAL_INVOICE_APPROVE(bookingId),
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Customer attaches the payment slip for the GL final invoice (export). */
|
||||
uploadFinalInvoiceSlip: async (
|
||||
bookingId: string,
|
||||
|
||||
@@ -12,7 +12,8 @@ export type PaymentMethod =
|
||||
| "WAAFI"
|
||||
| "CARD"
|
||||
| "DMONEY"
|
||||
| "CAC_BANK";
|
||||
| "CAC_BANK"
|
||||
| "CBE_BILL";
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
|
||||
@@ -26,13 +27,17 @@ export interface InitiatePaymentPayload {
|
||||
}
|
||||
|
||||
export interface ClientAction {
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
shortCode?: string;
|
||||
providerOrderId?: string;
|
||||
message?: string;
|
||||
/** SHOW_BILL_REFERENCE (CBE bill payment) */
|
||||
billReference?: string;
|
||||
instructions?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateResponse {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3): new inbound biller
|
||||
-- method. Value must stay byte-identical to @edr/types ProviderMethod.CBE_BILL — the
|
||||
-- passenger payments service casts between the two enums directly.
|
||||
ALTER TYPE "passenger"."PaymentMethodType" ADD VALUE IF NOT EXISTS 'CBE_BILL';
|
||||
@@ -147,6 +147,7 @@ enum PaymentMethodType {
|
||||
WAAFI
|
||||
DMONEY
|
||||
CAC_BANK
|
||||
CBE_BILL
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
@@ -109,6 +111,7 @@ export class BookingsService {
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly fareEngine: FareEngineService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
) {}
|
||||
|
||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||
@@ -2129,6 +2132,14 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
|
||||
// Verify-before-cancel: a still-PENDING_PAYMENT booking may actually be paid (its confirm event
|
||||
// was lost/late). reconcileAndConfirmIfPaid confirms it synchronously if so — refuse to cancel a
|
||||
// paid, or currently-unverifiable, booking as "unpaid".
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) throw new BadRequestException('Payment for this booking has completed; it is now confirmed and cannot be cancelled as unpaid.');
|
||||
if (!verified) throw new BadRequestException('Could not verify payment status right now; please try again shortly.');
|
||||
}
|
||||
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
@@ -2265,11 +2276,24 @@ export class BookingsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expirePendingBookings() {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
// NEUTRALIZED (was 20 minutes): the payment window is MAX_PAYMENT_HOURS (2h). Bookings must
|
||||
// NEVER be cancelled at 20 minutes — the payer still has up to 2 hours, and the seat hold is
|
||||
// held for exactly this window. Aligned to the 2-hour window so this cron can only ever act as
|
||||
// a safe backup to the primary deadline-aware sweep (TasksService.cancelExpiredPendingBookings);
|
||||
// it never cancels prematurely, and paid bookings are still protected by the guard below.
|
||||
const cutoff = new Date(Date.now() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
|
||||
for (const b of expired) {
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
try {
|
||||
// Never cancel a paid booking whose confirm event was lost/late — verify first (this
|
||||
// confirms it synchronously if paid). Skip when paid or currently unverifiable.
|
||||
const { paid, verified } = await this.paymentsService.reconcileAndConfirmIfPaid(b.id);
|
||||
if (paid || !verified) continue;
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
} catch (err) {
|
||||
this.logger.error(`expirePendingBookings failed for ${b.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
/**
|
||||
@@ -18,6 +24,9 @@ import { PaymentsService } from "./payments.service";
|
||||
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
// isPublic only skips the global IAM user-JWT guard — these routes stay protected by
|
||||
// ServiceAuthGuard's shared service token (the payment service is not an IAM user).
|
||||
@SetMetadata("isPublic", true)
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentsController {
|
||||
@@ -32,4 +41,16 @@ export class InternalPaymentsController {
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentsService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.paymentsService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,24 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: PaymentReferenceType;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ export interface PaymentDiagnostic {
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/** Settlement check from POST /payments/reconcile (verify-before-cancel). */
|
||||
export interface SettlementResult {
|
||||
/** At least one intent for the order is paid (incl. a late capture just registered). */
|
||||
paid: boolean;
|
||||
/** The paying intent when `paid`. */
|
||||
intent?: PaymentIntentSnapshot;
|
||||
/** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the
|
||||
* payment service was unreachable. The caller MUST NOT cancel the order. */
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
@@ -85,6 +96,31 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check before cancelling an order. Live-queries every
|
||||
* intent at the provider and registers any late capture found. A transport failure (payment
|
||||
* service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so
|
||||
* the caller does not cancel a booking whose payment simply could not be verified.
|
||||
*/
|
||||
async reconcileByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<SettlementResult> {
|
||||
try {
|
||||
return await this.call<SettlementResult>("POST", "/payments/reconcile", {
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`,
|
||||
);
|
||||
return { paid: false, unverifiable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
|
||||
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
|
||||
// This service keeps only singleton deps so its @Cron method registers correctly,
|
||||
@@ -16,12 +14,11 @@ export class PaymentSyncService {
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// Every 30 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
@@ -30,7 +27,7 @@ export class PaymentSyncService {
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
@Cron('*/30 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
@@ -47,7 +44,6 @@ export class PaymentSyncService {
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
@@ -59,37 +55,17 @@ export class PaymentSyncService {
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
// Reconcile ALL intents at the provider — including terminal (cancelled/expired) ones —
|
||||
// and confirm synchronously if any is paid. Unlike getIntentByReference this catches BOTH
|
||||
// a lost confirm event (payment-api already SUCCEEDED) AND a payment recorded only at the
|
||||
// provider (local intent terminal). Idempotent, so a re-run is safe.
|
||||
const { paid } = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (paid) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
// not paid / unverifiable → still pending; retried next cycle (or cancelled at deadline)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
@@ -99,10 +75,9 @@ export class PaymentSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
if (confirmed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
`Payment sync run: ${bookings.length} checked, ${confirmed} confirmed, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum {
|
||||
CAC_BANK = "CAC_BANK", // Djibouti (OTP debit)
|
||||
CARD = "CARD", // International
|
||||
WALLET = "WALLET", // Internal
|
||||
CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number)
|
||||
}
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
@@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@ApiPropertyOptional({
|
||||
@@ -135,6 +138,14 @@ export class ClientActionDto {
|
||||
providerOrderId?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
import { TicketsService } from "../tickets/tickets.service";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
@@ -27,6 +28,7 @@ describe("PaymentsService", () => {
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
paymentIntent: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -81,6 +83,8 @@ describe("PaymentsService", () => {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100),
|
||||
convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -109,6 +113,7 @@ describe("PaymentsService", () => {
|
||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||
{ provide: AuditService, useValue: { log: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
PaymentRegionEnum,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payments.dto";
|
||||
import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
@@ -222,6 +227,17 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT
|
||||
// required — CBE identifies the payer at its own channel.
|
||||
if (
|
||||
method === PaymentMethodType.CBE_BILL &&
|
||||
(booking.currency ?? "ETB").toUpperCase() !== "ETB"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for bookings charged in ETB",
|
||||
);
|
||||
}
|
||||
|
||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||
|
||||
// Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
|
||||
@@ -244,74 +260,9 @@ export class PaymentsService {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
// Double-charge guard for payment-method switches. Before opening a fresh charge over
|
||||
// this booking, reconcile any still-open intent against the authoritative provider
|
||||
// status — the booking-status check above only blocks once the booking is CONFIRMED,
|
||||
// which leaves a window where the first attempt actually paid but the mark-paid
|
||||
// webhook/poll hasn't landed yet.
|
||||
const existingIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
|
||||
);
|
||||
}
|
||||
|
||||
// The previous attempt actually paid (provider SUCCEEDED, event just late):
|
||||
// converge the booking now and return it — never charge a second time.
|
||||
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
// A web↔mobile switch needs a different clientAction (Telebirr: REDIRECT for web
|
||||
// vs LAUNCH_APP for the native app). Detect the open session's platform from its
|
||||
// clientAction shape so a platform change is NOT blocked below: it must flow
|
||||
// through to re-initiate, where the payment service retires the stale session and
|
||||
// opens a fresh one with the correct launch method for the requested platform.
|
||||
const requestedMobile = (dto.platform ?? "web") === "mobile";
|
||||
const storedAction = (snapshot?.clientAction ??
|
||||
existingIntent.clientAction) as unknown as ClientAction | null;
|
||||
const platformChanged =
|
||||
(storedAction?.type === "LAUNCH_APP") !== requestedMobile;
|
||||
|
||||
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
|
||||
// service was unreachable and the local status is non-terminal. Block the switch:
|
||||
// return the existing intent so the payer completes or waits out the open attempt
|
||||
// rather than opening a second concurrent charge. A platform switch is exempt — it
|
||||
// falls through so a session with the correct clientAction is opened for it.
|
||||
if (
|
||||
!platformChanged &&
|
||||
(!snapshot ||
|
||||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||
snapshot.status === ProviderPaymentStatus.PROCESSING)
|
||||
) {
|
||||
const intent = snapshot
|
||||
? await this.syncIntentProjection(booking.id, snapshot)
|
||||
: existingIntent;
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
// Otherwise the provider reports FAILED/CANCELLED, or the payer switched platform —
|
||||
// fall through and initiate the newly selected method below.
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
|
||||
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
@@ -324,15 +275,22 @@ export class PaymentsService {
|
||||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||
where: { type: method },
|
||||
});
|
||||
const chargeCurrency = (
|
||||
paymentMethod?.currency ?? booking.currency
|
||||
).toUpperCase();
|
||||
const chargeCurrency =
|
||||
method === PaymentMethodType.CBE_BILL
|
||||
? "ETB"
|
||||
: (paymentMethod?.currency ?? booking.currency).toUpperCase();
|
||||
|
||||
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
|
||||
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
|
||||
|
||||
let chargeAmount: number;
|
||||
if (
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
// Force ETB, no conversion (D8) — eligibility was already checked above.
|
||||
chargeAmount = this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
);
|
||||
} else if (
|
||||
chargeCurrency === bookingDisplayCurrency &&
|
||||
chargeCurrency !== 'ETB' &&
|
||||
bookingDisplayTotalMinor != null
|
||||
@@ -350,6 +308,20 @@ export class PaymentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the
|
||||
// intent expiry is the booking's own payment deadline — never a provider-session TTL
|
||||
// (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response.
|
||||
let payerName: string | undefined;
|
||||
let expiresAt: string | undefined;
|
||||
if (method === PaymentMethodType.CBE_BILL) {
|
||||
payerName =
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName;
|
||||
expiresAt = (
|
||||
await this.computeBookingPaymentDeadline(booking.id)
|
||||
)?.toISOString();
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.PASSENGER,
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
@@ -362,6 +334,8 @@ export class PaymentsService {
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl,
|
||||
failureUrl,
|
||||
payerName,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
let intent = await this.syncIntentProjection(booking.id, snapshot);
|
||||
@@ -415,6 +389,97 @@ export class PaymentsService {
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check +
|
||||
* payer identity for a booking. Called by the payment service while a CBE teller/app is
|
||||
* waiting — read-only and fast. This is the double-payment guard: once the booking is
|
||||
* confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3).
|
||||
*/
|
||||
async billQuery(bookingId: string): Promise<BillQueryResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, passenger: { include: { user: true } } },
|
||||
});
|
||||
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
|
||||
|
||||
const base = {
|
||||
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
||||
payerName:
|
||||
booking.seats.find((s) => s.leg === 1)?.passengerName ??
|
||||
booking.seats[0]?.passengerName ??
|
||||
booking.passenger?.user?.fullName ??
|
||||
null,
|
||||
currentAmountMinor: this.currencyService.displayMinorToChargeMajor(
|
||||
booking.totalMinor,
|
||||
"ETB",
|
||||
),
|
||||
currency: "ETB",
|
||||
};
|
||||
|
||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||
}
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||
}
|
||||
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
||||
if (deadline && deadline.getTime() < Date.now()) {
|
||||
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||
}
|
||||
return { ...base, stillPayable: true, reason: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's
|
||||
* origin-segment time and that stop's own check-in window, falling back to the route default.
|
||||
*/
|
||||
private async computeBookingPaymentDeadline(
|
||||
bookingId: string,
|
||||
): Promise<Date | null> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
createdAt: true,
|
||||
originStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
departureAt: true,
|
||||
stopTimes: {
|
||||
select: {
|
||||
stationId: true,
|
||||
plannedArrivalAt: true,
|
||||
plannedDepartureAt: true,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
select: {
|
||||
checkinMinutesBefore: true,
|
||||
stops: {
|
||||
select: { stationId: true, checkinMinutesBefore: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!booking?.schedule) return null;
|
||||
const originStop = booking.schedule.stopTimes?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const dep = (originStop?.plannedArrivalAt ??
|
||||
originStop?.plannedDepartureAt ??
|
||||
booking.schedule.departureAt) as Date;
|
||||
const originRouteStop = booking.schedule.route?.stops?.find(
|
||||
(s) => s.stationId === booking.originStationId,
|
||||
);
|
||||
const checkinMinutes =
|
||||
originRouteStop?.checkinMinutesBefore ??
|
||||
booking.schedule.route?.checkinMinutesBefore ??
|
||||
undefined;
|
||||
return computePaymentDeadline(booking.createdAt, dep, checkinMinutes);
|
||||
}
|
||||
|
||||
private resolveReturnUrls(
|
||||
method: PaymentMethodType,
|
||||
requestOrigin?: string | null,
|
||||
@@ -898,10 +963,76 @@ export class PaymentsService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the payment service (over HTTP — bypassing the possibly-down RabbitMQ) whether a booking is
|
||||
* actually paid, and CONFIRM it synchronously if so. Used by (a) every cancellation site as a
|
||||
* verify-before-cancel guard, and (b) the PaymentSyncService poller as lost-event recovery. Unlike
|
||||
* getIntentByReference, POST /payments/reconcile loops ALL intents and live-queries even
|
||||
* terminal (cancelled/expired) ones — so it catches a payment recorded only at the provider.
|
||||
*
|
||||
* - paid → the confirming payment is synced + finalized HERE (synchronously); the booking is
|
||||
* now CONFIRMED, so a cancellation caller must NOT cancel.
|
||||
* - not paid → verified unpaid; a cancellation caller may proceed.
|
||||
* - unverifiable (provider query errored, in-flight, or payment service unreachable) → a
|
||||
* cancellation caller must NOT cancel this cycle; defer and retry later.
|
||||
*/
|
||||
async reconcileAndConfirmIfPaid(
|
||||
bookingId: string,
|
||||
): Promise<{ paid: boolean; verified: boolean }> {
|
||||
const settlement = await this.paymentClient.reconcileByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
|
||||
if (settlement.unverifiable) {
|
||||
this.logger.warn(
|
||||
`reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`,
|
||||
);
|
||||
return { paid: false, verified: false };
|
||||
}
|
||||
|
||||
if (settlement.paid) {
|
||||
if (settlement.intent) {
|
||||
// Paid, but the confirm event may have been lost. Confirm synchronously (idempotent).
|
||||
const intent = await this.syncIntentProjection(
|
||||
bookingId,
|
||||
settlement.intent,
|
||||
);
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: settlement.intent.providerTxnId,
|
||||
paidAt: settlement.intent.paidAt
|
||||
? new Date(settlement.intent.paidAt)
|
||||
: undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: finalize failed for booking ${bookingId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
this.logger.log(
|
||||
`reconcile-before-cancel: booking ${bookingId} is PAID (${settlement.intent.merchantOrderId}) — confirmed, NOT cancelling`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`reconcile-before-cancel: booking ${bookingId} reported PAID but no intent snapshot — NOT cancelling`,
|
||||
);
|
||||
}
|
||||
return { paid: true, verified: true };
|
||||
}
|
||||
|
||||
// Verified not paid — safe to cancel.
|
||||
return { paid: false, verified: true };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
|
||||
force?: boolean;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
@@ -911,32 +1042,35 @@ export class PaymentsService {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
|
||||
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
|
||||
// generate a ticket.
|
||||
const idempotencyBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (idempotencyBooking?.status === "CONFIRMED") {
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException(
|
||||
"PaymentIntent is cancelled; cannot finalize",
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
@@ -944,7 +1078,24 @@ export class PaymentsService {
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const paidAt = this.sanitizePaidAt(input.paidAt);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
|
||||
// Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST
|
||||
// success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional
|
||||
// update is the race guard: two simultaneous payments both reach here, but exactly one flips
|
||||
// PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment
|
||||
// is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this
|
||||
// row SUCCEEDED). `force` (staff) confirms regardless of the current booking status.
|
||||
const confirmed = await this.prisma.$transaction(async (tx) => {
|
||||
const res = input.force
|
||||
? await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: { not: "CONFIRMED" } },
|
||||
data: { status: "CONFIRMED" },
|
||||
})
|
||||
: await tx.booking.updateMany({
|
||||
where: { id: booking.id, status: "PENDING_PAYMENT" },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
if (res.count === 0) return 0;
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
@@ -952,14 +1103,23 @@ export class PaymentsService {
|
||||
providerTxnId:
|
||||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
return res.count;
|
||||
});
|
||||
|
||||
if (confirmed === 0) {
|
||||
// Booking already confirmed by another payment (or not payable and not forced). This capture
|
||||
// is registered on the payment-api ledger; do not confirm, ticket, or touch this row.
|
||||
this.logger.error(
|
||||
`Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` +
|
||||
`txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
@@ -1087,38 +1247,93 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor`
|
||||
// carries the charge amount in MAJOR units (the intent's "real/major price" — what
|
||||
// initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so
|
||||
// normalize before comparing; a short payment must NOT confirm the booking. Amount-only —
|
||||
// the display↔charge currency divergence is tracked separately under the USD/DJF
|
||||
// findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100;
|
||||
const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01);
|
||||
if (event.amountMinor < expectedMajor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: event.referenceId,
|
||||
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
|
||||
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
|
||||
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
|
||||
if (booking.status === "CANCELLED") {
|
||||
await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: this.sanitizePaidAt(
|
||||
event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
),
|
||||
},
|
||||
});
|
||||
this.logger.error(
|
||||
`Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` +
|
||||
`(seats may be reassigned). Refund required.`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Sequential duplicate on an already-CONFIRMED booking: this success is a second payment,
|
||||
// already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep
|
||||
// the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.)
|
||||
if (booking.status !== "PENDING_PAYMENT") {
|
||||
this.logger.error(
|
||||
`Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` +
|
||||
`txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`,
|
||||
);
|
||||
return { processed: true, alreadyFinalized: true };
|
||||
}
|
||||
|
||||
// Booking is payable — point the single passenger projection row at THIS paying session (so the
|
||||
// row reflects the payment that confirms the booking, even if the payer switched methods), then
|
||||
// finalize (which does the atomic confirm-once).
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
create: {
|
||||
bookingId: event.referenceId,
|
||||
amountMinor: event.amountMinor,
|
||||
currency: event.currency,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
merchantOrderId: event.merchantOrderId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
});
|
||||
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
@@ -1174,6 +1389,7 @@ export class PaymentsService {
|
||||
return this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
force: true,
|
||||
}).then(async (result) => {
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
|
||||
return result;
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -28,6 +30,10 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
// ModuleRef (NOT direct injection): PaymentsService is request-scoped (AuditService injects
|
||||
// REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped
|
||||
// too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead.
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -290,6 +296,14 @@ export class TasksService {
|
||||
|
||||
let cancelledCount = 0;
|
||||
|
||||
// resolve() (not direct injection) because PaymentsService is request-scoped — same pattern
|
||||
// as PaymentSyncService. strict:false resolves it from the app context.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
|
||||
@@ -308,6 +322,18 @@ export class TasksService {
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded
|
||||
// event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck
|
||||
// PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously
|
||||
// if paid. Only proceed to cancel when settlement is VERIFIED unpaid.
|
||||
const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id);
|
||||
if (settlement.paid || !settlement.verified) {
|
||||
this.logger.log(
|
||||
`Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user