enhance gate pass and freight payment handling in train scheduling

- Updated the logic in  to ensure that a booking only earns its gate pass once the freight charges are settled.
- Added logging for bookings that have not settled freight payment when securing gate passes.
- Modified seeders to ensure that bookings have associated company profiles to prevent data inconsistencies.
- Updated freight permissions to include new clearance actions for bookings.
- Enhanced the UI to reflect changes in the clearance process, including new shipment request pages and improved status handling in the clearance action panel.
- Adjusted the contract clearance list to accommodate both customs contracts and shipment bookings.
- Improved the handling of GENERAL contracts in various components to ensure proper booking flow and visibility.
This commit is contained in:
Marshal
2026-07-09 07:19:36 +00:00
parent 1b2b3f68f8
commit cd8fb2b321
20 changed files with 959 additions and 126 deletions

View File

@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
/**
* Risk assignment is gated on the T1 being closed (catalog order
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
*/
function makeService(t1Status: Status | 'MISSING') {
const rows = new Map<string, ClearanceMilestone>();
if (t1Status !== 'MISSING') {
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
}
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
rows.set('RISK_ASSIGNED', risk);
const repo = {
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
Promise.resolve(rows.get(where.milestoneCode) ?? null),
),
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
};
const dataSource = { getRepository: () => repo } as unknown as DataSource;
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
}
describe('ClearanceMilestoneService.assignRisk', () => {
it('rejects the assignment while the T1 is still open', async () => {
const { service, repo } = makeService('PENDING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
const { service, repo } = makeService('MISSING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('assigns the risk level once the T1 is closed', async () => {
const { service, risk } = makeService('COMPLETED');
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('RED');
expect(risk.triggeredByUserId).toBe('user-1');
});
it('assigns the risk level when the T1 step was skipped', async () => {
const { service } = makeService('SKIPPED');
const saved = await service.assignRisk('b-1', 'YELLOW');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
* milestone metadata so the timeline shows it.
*
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED.
*/
async assignRisk(
bookingId: string,
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
private async assertT1Closed(bookingId: string): Promise<void> {
const t1 = await this.repo.findOne({
where: { bookingId, milestoneCode: 'T1_CLOSED' },
});
if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
throw new BadRequestException(
'The T1 must be closed before a customs risk level can be assigned.',
);
}
}
/**
* Advise duty & tax (amount + declaration serial) and complete the
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the

View File

@@ -122,6 +122,15 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
@@ -140,9 +149,10 @@ export class ContractBookingService {
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms && !isIntercity) {
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
@@ -174,7 +184,10 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -259,9 +272,10 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
const intendedStatus = generalCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&

View File

@@ -632,16 +632,15 @@ export class ContractTransitionService {
contract.customsClearingEnabled ?? false,
);
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
// level: there is no contract clearance cycle. The contract just becomes
// active; the customer then files shipment requests and GL books + clears
// each one. ONE_TIME customs and Path A self-clearance keep the contract
// cycle below.
const isGeneralCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneralCustoms) {
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
@@ -652,8 +651,8 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
// clears per booking). Ready for shipment requests / direct booking.
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';