Merge branch 'dev' into freight/nati-1

This commit is contained in:
Nathnael
2026-07-29 13:39:20 +00:00
269 changed files with 12185 additions and 5087 deletions

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
@@ -287,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,
@@ -331,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(
@@ -362,6 +401,32 @@ export class BookingLifecycleNotifierService {
);
}
/** 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 =
`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`,
});
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =

View File

@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };
}

View File

@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };
}

View File

@@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
@@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -7,7 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -56,11 +56,12 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
private isPhasedCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
@@ -376,6 +377,8 @@ export class BookingTransitionService {
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// A ONE_TIME contract closes on its single shipment being delivered.
this.events.emit('booking.completed', { bookingId });
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
@@ -491,7 +494,7 @@ export class BookingTransitionService {
operationReady?: boolean;
}> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
return this.bookingClearanceService.getClearanceView(bookingId);
}
const { inputCode, outputCode, includesCustoms } =
@@ -650,7 +653,7 @@ export class BookingTransitionService {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
@@ -732,7 +735,7 @@ export class BookingTransitionService {
}
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
@@ -755,7 +758,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -767,7 +770,7 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
if (this.isPhasedCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -817,7 +820,7 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);

View File

@@ -823,6 +823,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@@ -872,6 +900,59 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
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.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })

View File

@@ -1,8 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import {
DataSource,
DeepPartial,
EntityManager,
FindOptionsWhere,
In,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -26,6 +34,22 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
/**
* Suspending a contract freezes its bookings, so they drop out of every
* scheduling pool. Filtering here (rather than letting the write guard throw)
* keeps the batch crons quiet — a frozen contract simply stops being a
* candidate until the suspension is lifted.
*/
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM freight.contracts c
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
))`;
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/**
* Suspending a contract freezes its bookings too, so the single write path
* every booking mutation funnels through is the place to enforce it — one
* guard instead of one per transition method.
*
* The batch/scheduling pools filter suspended contracts out up front
* (see {@link excludeSuspendedContract}), so the engine and its crons never
* reach a frozen booking and this only ever fires on a user-initiated action.
*
* ponytail: the seven `manager.getRepository(Booking)` writes inside
* train-scheduling transactions bypass this — they only run on bookings the
* pool already handed out, which the filter above has excluded. Move them onto
* this repository if that ever stops holding.
*/
private async assertContractNotSuspended(id: string): Promise<void> {
const row = await this.repository
.createQueryBuilder('booking')
.select('contract.status', 'status')
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.where('booking.id = :id', { id })
.getRawOne<{ status: string }>();
if (row?.status === 'SUSPENDED') {
throw new ConflictException(
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
);
}
}
override async update(
id: string,
data: DeepPartial<Booking>,
): Promise<Booking | null> {
await this.assertContractNotSuspended(id);
return super.update(id, data);
}
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
@@ -551,6 +611,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
bookingId: string,
type: ReviewNoteType,
): Promise<BookingReviewNote[]> {
return this.dataSource.getRepository(BookingReviewNote).find({
where: { bookingId, type },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
@@ -1032,7 +1103,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
.andWhere('scheduleBooking.id IS NULL')
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
@@ -1091,10 +1163,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1130,10 +1200,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1170,10 +1238,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')

View File

@@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
it('ONE_TIME contract shipments carry the same per-booking set', () => {
// Contracts no longer collect clearance documents — every shipment does,
// whatever kind of contract it draws on.
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
* DOMESTIC has no customs, so one shared set serves every intercity booking
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
@@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export const REVIEW_NOTE_TYPES = [
'CHANGES_REQUESTED',
'REJECTION',
'STAFF_NOTE',
/**
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -557,6 +557,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;

View File

@@ -15,6 +15,9 @@ const generalImportBooking = {
dutyRequired: true,
roHoldReason: null,
vesselDepartureDate: null,
// Djibouti already named the transit officer — the declaration gate is open.
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
transitAssigneeName: 'Ahmed Bourhan',
} as Booking;
const generalExportBooking = {
@@ -27,6 +30,8 @@ const generalExportBooking = {
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
@@ -38,10 +43,14 @@ function makeService(overrides?: {
};
const filesService = {
upsertByCode: jest.fn().mockResolvedValue({}),
upload: jest.fn().mockResolvedValue({}),
deleteByCode: jest.fn().mockResolvedValue(undefined),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
getByCode: overrides?.docsApproved
? jest.fn().mockResolvedValue({ fields: [] })
: jest.fn().mockRejectedValue(new Error('no setting')),
};
const workflowService = {
assertPriorCompleteForBooking: overrides?.workflowThrows
@@ -49,6 +58,7 @@ function makeService(overrides?: {
: jest.fn().mockResolvedValue(undefined),
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
@@ -91,7 +101,15 @@ function makeService(overrides?: {
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
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 {
@@ -186,6 +204,82 @@ describe('BookingClearanceService', () => {
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an import declaration before Djibouti names the transit officer', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toThrow(/Request a transit assignee/i);
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
});
it('lets the export declaration through without a transit assignee', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalExportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await service.uploadDeclaration('b-export', [
{ fieldname: 'decl' } as Express.Multer.File,
]);
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
});
});
describe('transit assignee handshake', () => {
it('refuses an assignment GL Ethiopia never asked for', async () => {
const { service } = makeService({
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
).rejects.toThrow(/has not requested a transit assignee/i);
});
it('stamps the ask and then the name', async () => {
const { service, bookingsRepository } = makeService({
booking: {
...generalImportBooking,
transitAssigneeName: null,
} as Booking,
});
await service.requestTransitAssignee('b-general', ' night shift ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeRequestedAt: expect.any(Date),
transitAssigneeRequestNote: 'night shift',
}),
);
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeName: 'Ahmed Bourhan',
transitAssigneeAssignedAt: expect.any(Date),
}),
);
});
});
describe('uploadReleaseOrder', () => {

View File

@@ -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';
@@ -71,12 +76,44 @@ export interface BookingClearanceView {
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
/**
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the import customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* 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.
*/
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;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
@@ -87,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). */
@@ -97,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()
@@ -111,15 +152,14 @@ 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 assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
@@ -127,7 +167,7 @@ export class BookingClearanceService {
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
await this.assertPhasedCustoms(booking);
return booking;
}
@@ -214,6 +254,11 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
bookingId,
milestones,
);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
@@ -241,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,
@@ -272,8 +323,21 @@ export class BookingClearanceService {
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
transitAssignee: {
requestedAt: booking.transitAssigneeRequestedAt
? booking.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: booking.transitAssigneeRequestNote ?? null,
name: booking.transitAssigneeName ?? null,
assignedAt: booking.transitAssigneeAssignedAt
? booking.transitAssigneeAssignedAt.toISOString()
: null,
},
dutyAdvice,
draftDeclaration,
draftDeclarationChangeRequest,
workflowFiles,
exchangeDocuments,
t1,
train,
gatepassGranted: gatepass.granted,
@@ -284,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'
@@ -330,6 +395,46 @@ export class BookingClearanceService {
};
}
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['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 {
note: latest.note,
raisedAt: latest.createdAt.toISOString(),
rounds: notes.length,
};
}
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true;
@@ -352,14 +457,59 @@ export class BookingClearanceService {
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
);
}
/**
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
* in transit. The import declaration is gated on the answer, so this is the
* first thing ET does once the customer documents are approved. Re-requesting
* is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
}
/**
* 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, transitAgentId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
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: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
@@ -373,6 +523,16 @@ export class BookingClearanceService {
'All required customer documents must be approved before uploading a declaration.',
);
}
// Import only: the declaration is filed against whoever physically handles
// the shipment in Djibouti, so that name must be in first. Exports have no
// such handshake — their Djibouti steps come after the declaration.
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
@@ -398,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);
}
@@ -460,6 +614,124 @@ export class BookingClearanceService {
return this.bookingsService.findById(bookingId);
}
/**
* 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 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('Draft declaration applies only to import bookings.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what needs to change so GL can correct the draft.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to request a change on yet.');
}
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
throw new BadRequestException(
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
'DRAFT_DECL_CHANGE_REQUEST',
userId,
);
// 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.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
@@ -664,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 };
}
@@ -720,7 +996,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
@@ -733,7 +1009,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {

View File

@@ -60,6 +60,11 @@ export class BookingRequestService {
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',

View File

@@ -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 },

View File

@@ -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',

View File

@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
expect(contractsRepository.update).not.toHaveBeenCalled();
});
describe('completion on booking delivery', () => {
function makeDeliveryService(contract: Partial<Contract>) {
const contractsRepository = {
findById: jest.fn().mockResolvedValue(contract),
update: jest.fn().mockResolvedValue(undefined),
};
const bookingsRepository = {
findById: jest
.fn()
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ createdToStaff: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, contractsRepository };
}
it('completes a ONE_TIME contract when its booking is delivered', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
bulk: null,
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
contractKind: 'GENERAL',
status: 'CONTRACT_ACTIVE',
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(

View File

@@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{
createdToStaff: jest.fn(),

View File

@@ -0,0 +1,74 @@
import { ForbiddenException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Who may open a shipment instance on a customs (Path B) contract. The customer
* initiates his own ONE_TIME customs booking and uploads the GL-input documents
* on it; GL still clears it and completes it with cargo and price. GENERAL
* customs instances come from a shipment request, and completing/creating a
* customs booking outright stays GL-only.
*/
describe('ContractBookingService — customs booking gate', () => {
function makeService() {
return new ContractBookingService(
{} as never, // contractsRepository
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
}
type WithPrivate = {
assertGate: (
c: Contract,
isGlActor: boolean,
isInitiate?: boolean,
) => Promise<string>;
};
const customsContract = (contractKind: 'ONE_TIME' | 'GENERAL'): Contract =>
({
id: 'c-1',
contractKind,
status: 'FULLY_EXECUTED',
customsClearingEnabled: true,
}) as Contract;
const gate = (c: Contract, isGl: boolean, isInitiate?: boolean) =>
(makeService() as never as WithPrivate).assertGate(c, isGl, isInitiate);
it('lets the customer initiate a ONE_TIME customs shipment', async () => {
await expect(gate(customsContract('ONE_TIME'), false, true)).resolves.toBe(
'CUSTOMER',
);
});
it('still lets GL initiate on the customer behalf', async () => {
await expect(gate(customsContract('ONE_TIME'), true, true)).resolves.toBe(
'GL_ET',
);
});
it('rejects a customer creating a customs booking outright (cargo + day)', async () => {
await expect(gate(customsContract('ONE_TIME'), false)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('rejects a customer initiating a GENERAL customs shipment (request only)', async () => {
await expect(
gate(customsContract('GENERAL'), false, true),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import {
ContractsRepository,
TERMINAL_BOOKING_STATUSES,
} from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
// same list.
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
@@ -94,7 +98,6 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -187,21 +190,14 @@ export class ContractBookingService {
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
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). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// EVERY contract booking clears per booking now — both contract kinds, both
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
// workflow on this booking. Non-customs (Path A) and intercity: the customer
// uploads his own document set on the booking and Operations reviews it
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation; intercity finalize goes straight to the ride-along pool).
// So the booking is always born in the clearance gate, never in the
// operations queue, and no contract-level clearance cycle exists to link.
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
@@ -218,24 +214,11 @@ export class ContractBookingService {
throw new BadRequestException('A binding shipment day is required');
}
// 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. 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,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// No booking-window / export-space gate here any more: every contract
// booking enters the clearance gate first and is scheduled only once the
// documents are approved. Both checks run at that point instead —
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
// created with cargo) — against the day the customer actually picks.
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
@@ -265,10 +248,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -375,10 +355,7 @@ export class ContractBookingService {
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus = 'AWAITING_DOCUMENTS';
if (
withContainers &&
freightType === 'CONTAINER' &&
@@ -404,11 +381,7 @@ export class ContractBookingService {
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
@@ -417,13 +390,22 @@ export class ContractBookingService {
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
* or GENERAL, customs or not. One click, zero input: no schedule date, no
* cargo, no window check, no pricing. The instance starts in the clearance
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
*
* - Path A (self-clearance): the customer initiates, uploads his clearance
* proof, Operations reviews and finalizes.
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
* GL-input documents on the instance; GL approves them and runs the phased
* ET/DJ workflow (pre-booking milestones are seeded here). GL may still
* initiate on his behalf. GENERAL customs instances come from a shipment
* request ({@link initiateForShipmentRequest}), not from here.
*
* Only after the clearance is finalized is the booking completed (cargo +
* binding day + window check) via {@link completeUnderContract} — by the
* customer on Path A, by GL on Path B.
*/
async initiateUnderContract(
contractId: string,
@@ -434,13 +416,12 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
// Intercity has no shipment day to defer to, so it is booked directly with
// its cargo (the documents still live on that booking). Everything else —
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
if (contract.tradeDirection === 'DOMESTIC') {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
);
}
@@ -453,12 +434,28 @@ export class ContractBookingService {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
// completion enforces that the next booking takes the whole remainder.
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
@@ -503,6 +500,16 @@ export class ContractBookingService {
} as never),
);
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
@@ -718,6 +725,15 @@ export class ContractBookingService {
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
// ONE_TIME split chain: the instance that follows a paid partial must take
// the WHOLE outstanding remainder — same rule a booking created with cargo
// passes at creation.
if (
contract.contractKind === 'ONE_TIME' &&
(await this.hasSplitBooking(contract.id))
) {
await this.assertExactRemainder(contract, dto);
}
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
@@ -817,10 +833,7 @@ export class ContractBookingService {
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
@@ -886,33 +899,17 @@ export class ContractBookingService {
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
// Customs runs per booking for BOTH contract kinds: seed the full milestone
// timeline on the booking. ensure* skips codes that already exist — an
// initiated instance carries its pre-booking milestones from initiation, and
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
@@ -982,10 +979,7 @@ export class ContractBookingService {
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
await this.finalizeContractBooking(id, contract).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
@@ -1000,36 +994,38 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
private async assertGate(
contract: Contract,
isGlActor: boolean,
isInitiate = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
if (contract.status === 'SUSPENDED') {
throw new BadRequestException(
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
);
}
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
if (!isGlActor) {
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
// contract (one click, no cargo) and uploads the GL-input documents on it;
// GL still runs the phased ET/DJ clearance and completes the booking with
// cargo, day and price. A GENERAL customs instance is opened by a shipment
// request instead, and completing any customs booking stays GL-only.
const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME';
if (!isGlActor && !customerMayInitiate) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — pre-booking boundary milestone must be complete.
const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Pre-booking clearance is not complete — booking cannot be created yet.',
'Contract must be fully executed before booking a shipment.',
);
}
return 'GL_ET';
return isGlActor ? 'GL_ET' : 'CUSTOMER';
}
// Path A — customer (or staff) once the contract is executed.
@@ -1041,6 +1037,40 @@ export class ContractBookingService {
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
/**
* GL fallback worklist: executed ONE_TIME customs contracts with no live
* shipment instance yet. The customer normally opens it himself from the
* portal; this list lets GL do it on his behalf, and shows the contracts that
* are on no other queue (clearance lives on the booking, which does not exist
* yet). GENERAL customs is excluded — opened by shipment requests.
*/
async awaitingShipmentContracts(): Promise<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['FULLY_EXECUTED'],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: 'createdAt',
sortOrder: 'DESC',
} as never);
const out: Contract[] = [];
for (const contract of items) {
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
continue;
}
// A split chain frees the slot for the remainder, so those contracts stay
// on the list even while the paid partial booking still exists.
if (await this.hasSplitBooking(contract.id)) {
out.push(contract);
continue;
}
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
}
return out;
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
@@ -1418,6 +1448,53 @@ export class ContractBookingService {
];
}
/**
* A ONE_TIME contract carries exactly one shipment: once that booking is
* delivered (COMPLETED) the contract is fulfilled and moves to
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
* blocking any further booking. A split ONE_TIME is the exception: its
* remainder chain must be rebooked and delivered first, so the contract stays
* open while the split remainder is outstanding.
*
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
* Best-effort: a status hiccup must never fail the booking that completed.
*/
@OnEvent('booking.completed')
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(payload.bookingId);
if (!booking?.contractId) return;
const contract = await this.contractsRepository.findById(booking.contractId);
if (!contract || contract.contractKind === 'GENERAL') return;
// Already closed/expired/cancelled — nothing to do.
if (isEffectivelyExpired(contract)) return;
const outstanding = await this.splitOutstanding(contract);
if (outstanding) {
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
// cap-exhaustion path below.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
);
} catch (err) {
this.logger.error(
`Could not close contract for completed booking ${payload.bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract, and under a ONE_TIME

View File

@@ -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,
@@ -34,7 +36,7 @@ import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -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'
@@ -1060,46 +1068,6 @@ export class ContractClearanceService {
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
@@ -1157,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) {
@@ -1178,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;
}
@@ -1226,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,
@@ -1255,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);
}
@@ -1601,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 };
}
@@ -1693,80 +1667,4 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
}

View File

@@ -0,0 +1,79 @@
import { ConflictException } from '@nestjs/common';
import { ContractsService } from './contracts.service';
import type { CreateContractDto } from './dto/create-contract.dto';
/**
* The duplicate guard blocks a new request only when EVERY commercial
* dimension matches a live contract — service type, operation type, contract
* kind, cargo scope and route. Any one differing must let the request through.
*/
describe('ContractsService duplicate guard', () => {
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
const existing = {
id: 'c-1',
reference: 'CTR-2026-00001',
status: 'PENDING_APPROVAL',
contractValidUntil: null,
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
};
const dto = (overrides: Partial<CreateContractDto> = {}) =>
({
serviceTypeId: 'svc-1',
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
...overrides,
}) as CreateContractDto;
const guard = (input: CreateContractDto) => {
const service = new ContractsService(
{} as never,
{ findDuplicateCandidates: async () => [existing] } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return (
service as unknown as {
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
}
).assertNoDuplicateContract('company-1', input);
};
it('blocks an identical request', async () => {
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
});
it.each([
['operation type', { tradeDirection: 'EXPORT' }],
['contract kind', { contractKind: 'GENERAL' }],
['freight type', { freightType: 'BULK' }],
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
])('allows a request with a different %s', async (_label, overrides) => {
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
});
it('ignores quantity caps when comparing cargo scope', async () => {
await expect(
guard(
dto({
cargoScope: [
{ containerSize: '20ft', quantityCap: 10 },
{ containerSize: '40ft', quantityCap: 5 },
],
}),
),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -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'),

View File

@@ -143,6 +143,33 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/** Backoffice froze the contract — every action on it is blocked until lifted. */
suspended(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
void this.notifyContact(c, msg, 'SUSPENDED');
this.inApp(c, 'Contract suspended', msg);
}
/** Backoffice lifted the suspension — the contract resumes where it left off. */
suspensionLifted(c: Contract, note?: string | null): void {
const msg =
`The suspension on your contract ${c.reference} has been lifted. ` +
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
this.inApp(c, 'Contract suspension lifted', msg);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(
c,
'Contract cancelled by customer',
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their

View File

@@ -0,0 +1,132 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* Suspension is only worth having if it is reversible and if it actually
* freezes things, and the customer's own cancel is only safe while no shipment
* is running. Those three rules are the whole feature — everything else is
* plumbing.
*/
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
status: 'CONTRACT_ACTIVE',
freightType: 'CONTAINER',
...over,
}) as Contract;
let current: Contract;
let repo: {
update: jest.Mock;
createReviewNote: jest.Mock;
countActiveBookings: jest.Mock;
};
let notifier: {
suspended: jest.Mock;
suspensionLifted: jest.Mock;
cancelledByCustomer: jest.Mock;
};
let service: ContractTransitionService;
/** A staff user holding the suspend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
};
beforeEach(() => {
current = contract();
repo = {
// Mirror the real repository: the update patches the row the next
// findById returns, so resume() reads what suspend() wrote.
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
countActiveBookings: jest.fn().mockResolvedValue(0),
};
notifier = {
suspended: jest.fn(),
suspensionLifted: jest.fn(),
cancelledByCustomer: jest.fn(),
};
// These three transitions touch only the repository, the read-back service
// and the notifier — the other 14 constructor deps stay unused, so the
// instance is built bare and only what is exercised is injected.
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('freezes at the current step and remembers where to come back to', async () => {
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'SUSPENDED',
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
});
expect(notifier.suspended).toHaveBeenCalled();
});
it('restores the pre-suspension status when the suspension is lifted', async () => {
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
await service.resume('c-1', undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
statusBeforeSuspension: null,
});
});
it('refuses to suspend a contract the customer has not signed yet', async () => {
current = contract({ status: 'PENDING_APPROVAL' });
await expect(
service.suspend('c-1', 'too early', 'staff-1', staff as never),
).rejects.toThrow(/PENDING_APPROVAL/);
expect(repo.update).not.toHaveBeenCalled();
});
it('lets the customer cancel a contract with no live shipment', async () => {
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Changed supplier',
'CANCELLATION',
'user-1',
'CUSTOMER',
);
});
it('blocks the customer cancel while a shipment is still running', async () => {
repo.countActiveBookings.mockResolvedValue(2);
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/2 active shipments/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
current = contract({ status: 'SUSPENDED' });
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/suspended/);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,7 @@ import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import {
Contract,
ContractDocumentArticle,
@@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
.join(' and ');
}
/**
* Where the backoffice may freeze a contract: every step from the customer's
* signature onward, up to (but not including) the terminal states. Suspending
* an unsigned contract is meaningless — staff reject or request changes there.
*/
export const SUSPENDABLE_CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -154,7 +168,6 @@ export class ContractTransitionService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@@ -212,6 +225,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -228,6 +242,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -1262,43 +1277,16 @@ export class ContractTransitionService {
lockedAt: now,
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// No prepay gate: the customs clearance service fee (Path B) is billed on
// the booking invoice together with the freight, so the document step
// opens immediately.
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
// intercity. A signed contract carries no clearance cycle and collects no
// documents: the shipment instance created after signature does. Customs
// (Path B): the customer initiates the booking (GENERAL: via a shipment
// request) and uploads on it, GL reviews and completes it. Self-clearance
// (Path A) and intercity: the customer initiates/books and Operations
// reviews the booking documents.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
@@ -1308,6 +1296,123 @@ export class ContractTransitionService {
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
/**
* Backoffice freeze, available at every step from the customer signature
* onward. The pre-suspension status is stashed so {@link resume} can put the
* contract back exactly where it was — a suspension you cannot lift is just a
* cancellation under another name.
*
* While SUSPENDED nothing moves: no new bookings or shipment requests
* (ContractBookingService / BookingRequestService), and no writes to the
* contract's existing bookings (BookingsRepository.update).
*/
async suspend(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'SUSPENSION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'SUSPENDED',
statusBeforeSuspension: contract.status,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspended(updated, reason);
return updated;
}
/** Lift a suspension — the contract returns to the status it was frozen at. */
async resume(
contractId: string,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, ['SUSPENDED']);
// Legacy safety net: a row suspended before the column existed has nothing
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
// contract kinds, so it is the only sane default.
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
if (note?.trim()) {
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'SUSPENSION_LIFTED',
actorId,
'STAFF',
);
}
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspensionLifted(updated, note ?? null);
return updated;
}
/**
* Customer cancels their own contract so they can request a fresh one for the
* same lane — the duplicate-contract guard treats CANCELLED as released.
* Blocked while any booking on the contract is still live: cancelling a
* contract with cargo in motion would strand it.
*/
async cancelByCustomer(
contractId: string,
reason: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended by EDR — contact us to lift the suspension first.',
);
}
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
const body = reason?.trim() || 'Cancelled by the customer.';
await this.contractsRepository.createReviewNote(
contractId,
body,
'CANCELLATION',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByCustomer(updated, body);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
CancelContractDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -273,6 +276,18 @@ export class ContractsController {
return this.clearanceService.queue(filter);
}
// Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal
// path as an id and ParseUUIDPipe answered 400 "uuid is expected".
@Get('awaiting-shipment')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
})
awaitingShipmentContracts() {
return this.contractBookingService.awaitingShipmentContracts();
}
@Get(':id')
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@@ -453,6 +468,65 @@ export class ContractsController {
);
}
@Post(':id/suspend')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
})
suspend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SuspendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.suspend(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/resume')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
resume(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ResumeContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.resume(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
// through, everyone else must own the contract's company.
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.cancelByCustomer(
id,
dto.reason,
resolveAuthUserId(user),
);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@@ -763,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),
);
}
@@ -924,31 +998,8 @@ export class ContractsController {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
@@ -1017,7 +1068,7 @@ export class ContractsController {
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@@ -1243,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')
@@ -1335,7 +1400,7 @@ export class ContractsController {
) {
const file = (files ?? [])[0];
const booking = await this.bookingsService.findById(bookingId);
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);

View File

@@ -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 {}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
@@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
/**
* Booking statuses that release whatever the booking was holding — contract
* capacity, the one-time active slot, the cancel gate. Everything else counts
* as a live booking.
*/
export const TERMINAL_BOOKING_STATUSES = [
'EXPIRED',
'CANCELLED',
'COMPLETED',
'REJECTED',
];
export interface ContractListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* Non-terminal contracts for the same company + service type, with routes and
* cargo scope loaded — candidates for the duplicate-contract check on
* create() (which also compares operation type, kind and scope). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
@@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
@@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
// ── Review notes ──────────────────────────────────────────────────────────────
/**
* Bookings on the contract that have not reached a terminal state. Gates the
* customer's own contract cancellation (a contract carrying live cargo may
* not be cancelled) and is surfaced on the detail response so the portal can
* disable the button instead of failing the call.
*/
async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.getCount();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -10,7 +10,7 @@ import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
.join(', ');
}
/**
* Order-independent identity of a cargo scope — two contracts cover the same
* cargo only when they list the same container sizes / commodities. Quantity
* caps are deliberately ignored: they size a GENERAL contract, they don't make
* it a different scope.
*/
function cargoScopeKey(
scope?: Array<
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
> | null,
): string {
if (!scope?.length) return '';
return scope
.map((row) =>
[
row.containerSize?.trim().toLowerCase() ?? '',
row.cargoTypeId ?? '',
row.cargoFreeText?.trim().toLowerCase() ?? '',
].join('|'),
)
.sort()
.join(',');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -190,25 +214,34 @@ export class ContractsService {
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
* A live contract only blocks a new request when EVERY commercial dimension
* of the wizard matches it: service type, operation type (trade direction),
* contract kind, cargo scope and route. Change any one of them — a different
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
* customer may request another contract.
*
* A route "overlaps" if any origin/destination pair matches; cargo scope
* matches only when the two scope sets are identical (same freight type and
* the same container sizes / commodities).
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
dto: CreateContractDto,
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
dto.serviceTypeId,
);
const incomingScope = cargoScopeKey(dto.cargoScope);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
c.tradeDirection === dto.tradeDirection &&
c.contractKind === dto.contractKind &&
c.freightType === dto.freightType &&
cargoScopeKey(c.cargoScope) === incomingScope &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
dto.routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
@@ -220,7 +253,7 @@ export class ContractsService {
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
);
}
}
@@ -257,7 +290,7 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
await this.assertNoDuplicateContract(companyId, dto);
}
// Stamp the operational profile for portal scoping. A forwarder contract
@@ -813,6 +846,24 @@ export class ContractsService {
}
}
// Why the contract is frozen — shown to staff and customer alike.
if (contract.status === 'SUSPENDED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'SUSPENSION',
);
contract.latestSuspensionNote = note?.body ?? null;
} catch {
contract.latestSuspensionNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =
await this.contractsRepository.countActiveBookings(contract.id);
return contract;
}

View File

@@ -50,3 +50,17 @@ export class CancelContractDto {
@IsString()
reason?: string;
}
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()
@MinLength(1)
reason!: string;
}
export class ResumeContractDto {
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -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;
}
/**

View File

@@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
* correct it. One row per round — the advice/dispute loop can repeat.
*/
'DUTY_DISPUTE',
/** Backoffice froze the contract; body is the reason shown to the customer. */
'SUSPENSION',
/** Backoffice lifted a suspension; body is the optional lift note. */
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
// Reversible backoffice freeze — see statusBeforeSuspension.
'SUSPENDED',
'CONTRACT_CLOSED',
'EXPIRED',
'REJECTED',
@@ -214,9 +216,21 @@ export class Contract extends BaseEntity {
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* Status the contract held when the backoffice suspended it, restored when
* the suspension is lifted. Null unless the contract is (or once was)
* SUSPENDED. A suspension without this would just be a cancellation.
*/
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -343,4 +357,18 @@ export class Contract extends BaseEntity {
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
/**
* Body of the most recent SUSPENSION review note, attached by
* ContractsService.findById while the contract is SUSPENDED so both sides see
* why it was frozen. Lives in contract_review_notes, not a column here.
*/
latestSuspensionNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while
* it is > 0 (the API enforces the same). Not a column.
*/
activeBookingCount?: number;
}

View File

@@ -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' }),
);
});
});

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}

View File

@@ -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))

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -0,0 +1,29 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
/**
* One truck's detention window. Each truck reaches the destination and is
* released at its own time, so detention days differ between trucks on the
* same delivery. Null clears the value (falls back to the leg-level pair).
*/
export class TruckDetentionTimeInput {
@IsUUID()
vehicleId!: string;
/** Detention clock start — this truck reached the destination. */
@IsOptional()
@IsDateString()
destinationArrivedAt?: string | null;
/** Detention clock end — this truck was released/returned. Omit = still out. */
@IsOptional()
@IsDateString()
returnedAt?: string | null;
}
export class SetDetentionTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckDetentionTimeInput)
trucks!: TruckDetentionTimeInput[];
}

View File

@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
/**
* Detention clock START for THIS truck: reached the delivery destination.
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
* leg-level `last_mile.arrived_at`.
*/
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
destinationArrivedAt?: Date | null;
/**
* Detention clock END for THIS truck: released / returned by the customer.
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
*/
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
returnedAt?: Date | null;
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
grossWeightTons?: number | null;

View File

@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -131,6 +132,18 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/detention-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
})
async setDetentionTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDetentionTimesDto,
) {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -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,
@@ -869,6 +873,46 @@ export class LastMileService {
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step.
*/
/**
* Per-truck detention windows. Each truck reaches the destination and is
* released at its own time, so every truck gets its own clock (and therefore
* its own chargeable days). Locked once the detention invoice exists.
*/
async setDetentionTimes(
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Detention times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
const end = t.returnedAt ? new Date(t.returnedAt) : null;
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(
'A truck cannot be returned before it arrived — check the detention times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ destinationArrivedAt: start, returnedAt: end },
);
}
return this.findById(id);
}
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -1,13 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are
// only read by `GET /locomotives/paged` — the plain list ignores them.
export class FilterLocomotivesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
@@ -47,4 +50,14 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
excludeTrainId?: string;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -24,6 +24,14 @@ export class LocomotivesController {
return this.locomotivesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@StaffReference()
@ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAllPaged(filter);
}
@Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' })

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
* Filter/sort builder shared by the coupling picker and the paginated list:
* the usual status/type/yard filters, free-text over code + name, a
* registration-day range, and optional exclusion of any loco already coupled
* to a built train. `keepTrainId` spares that one train's own locos from the
* exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
buildListQuery(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
search?: string;
createdFrom?: string;
createdTo?: string;
}): SelectQueryBuilder<Locomotive> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
@@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
const search = opts.search?.trim();
if (search) {
qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', {
search: `%${search}%`,
});
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (opts.createdFrom) {
qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: opts.createdFrom,
});
}
if (opts.createdTo) {
qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: opts.createdTo,
});
}
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
@@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
return qb;
}
findForCoupling(opts: Parameters<LocomotivesRepository['buildListQuery']>[0]): Promise<Locomotive[]> {
return this.buildListQuery(opts).getMany();
}
/**

View File

@@ -1,6 +1,9 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -53,6 +56,21 @@ export class LocomotivesService {
});
}
/** Same filters as `findAll` plus search/date range, on the shared list envelope. */
findAllPaged(filter: FilterLocomotivesDto): Promise<PaginatedResponse<Locomotive>> {
const qb = this.locomotivesRepository.buildListQuery({
status: filter.status as LocomotiveStatus | undefined,
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
currentYardId: filter.currentYardId,
excludeCoupled: filter.excludeCoupled,
keepTrainId: filter.excludeTrainId,
search: filter.search,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
});
return paginateQuery(qb, filter);
}
/** Default max pull weight (tons) applied when the caller omits it. */
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;

View File

@@ -1,14 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { RouteStatus } from '../entities/route.entity';
export class FilterRoutesDto {
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
@IsOptional()
@IsString()
search?: string;
// `search` (origin/destination/milestone yard codes and names) plus
// `page`/`pageSize` come from the shared pagination DTO; the page window is only
// read by `GET /routes/paged`.
export class FilterRoutesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional()
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])

View File

@@ -21,6 +21,13 @@ export class RoutesController {
return this.routesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@ApiOperation({ summary: 'List routes, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterRoutesDto) {
return this.routesService.findAllPaged(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -4,9 +4,10 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { PaginatedResponse, TrainScheduleStatus } from '@edr/types';
import { DataSource, In, Not } from 'typeorm';
import { paginateArray } from '../../common/utils/pagination.util';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
@@ -68,6 +69,18 @@ export class RoutesService {
});
}
/**
* `findAll` on the shared `{items, meta}` envelope.
*
* ponytail: slices in memory — the corridor table is small (tens of rows) and
* both the ordering (formatted "A → B → C" label) and the search span the
* milestone collection, which a single SQL page window cannot express. Move to
* a query builder if routes ever grow past a few hundred.
*/
async findAllPaged(filter: FilterRoutesDto): Promise<PaginatedResponse<Route>> {
return paginateArray(await this.findAll(filter), filter);
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },

View File

@@ -1,5 +1,6 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
@@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => {
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
wagonStockForSchedule: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
@@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
// No physical stock configured → the wagon-type gate stands down and these
// specs keep testing the abstract capacity budget on its own.
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'YARD',
remainingByTypeId: new Map<string, number>(),
codesByTypeId: new Map<string, string>(),
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => {
};
await fn(manager);
}),
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
// Empty = unresolvable, so the physical-stock gate stands down and these
// specs keep exercising the abstract capacity budget alone.
query: jest.fn().mockResolvedValue([]),
};
notifier = {
@@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
return genericRepo;
}),
transaction: jest.fn(),
query: jest.fn().mockResolvedValue([]),
};
const service = new BookingBatchService(
dataSource as never,
@@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => {
});
});
});
/**
* The reported failure: a train advertising 20 free wagons where only 16 are of
* the type the booking can ride. Selecting all 20 took the customer's money for
* space that never existed and then stalled at allocation on wagon 17.
*/
describe('BookingBatchService — physical wagon-type gate', () => {
const NW5 = 'wagon-type-nw5';
const PW2 = 'wagon-type-pw2';
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
const internals = (svc: BookingBatchService) =>
svc as unknown as {
hasWagonStock: (
stock: WagonStockLedger,
ids: string[],
needed: number,
leg: { fromEdge: number; toEdge: number },
) => boolean;
maybeOfferPartial: (
booking: Booking,
isPair: boolean,
candidates: unknown[],
need: { wagons: number; weightTons: number; lengthMeters: number },
ids: string[],
) => Promise<boolean>;
tryPartialOffer: unknown;
isSplitEligible: unknown;
};
const service = () =>
new BookingBatchService(
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
const svc = internals(service());
const stock = mixedStock();
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
// A booking that may ride either type sees all 20.
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
});
it('stands down when the booking has no allowed wagon type configured', () => {
// Unresolvable configuration must not strand every booking that uses it —
// the abstract capacity budget still governs.
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
});
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
const svc = service();
const inner = internals(svc);
// Isolate the sizing decision: eligibility and offer creation are covered
// elsewhere, what matters here is the room handed to tryPartialOffer.
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
// 20 abstract slots free, weight and length wide open.
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const offered = await inner.maybeOfferPartial(
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
false,
[candidate],
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
[NW5],
);
expect(offered).toBe(true);
// 16, not the 20 free slots — the customer is billed for what can be loaded.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
});

View File

@@ -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,
@@ -87,6 +90,7 @@ import {
OverageTolerance,
stopYardsFor,
} from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util';
export type { Capacity } from './corridor-capacity.util';
@@ -166,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;
@@ -1320,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
@@ -1619,6 +1632,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const stock = await this.stockLedgerFor(schedule, budget);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
@@ -1629,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;
@@ -1655,14 +1670,19 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed.
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
`stocked=${stocked}`,
);
if (!budget.fits(need, leg)) {
if (!budget.fits(need, leg) || !stocked) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
@@ -1677,16 +1697,19 @@ export class BookingBatchService implements OnModuleInit {
// Doesn't fit whole. A split-eligible import booking is offered the part
// that fits in the remaining room (top-up path splits the boundary
// booking, mirroring fillRouteDay); otherwise skip and try the next.
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
id: scheduleId,
budget,
armed,
};
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
const cand: {
id: string;
budget: CorridorBudget;
armed: boolean;
stock: WagonStockLedger;
} = { id: scheduleId, budget, armed, stock };
if (
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
) {
armed = cand.armed;
continue;
}
continue; // skip a unit that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
}
}
@@ -1704,6 +1727,8 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
budget.subtract(need, leg);
// Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -1823,14 +1848,20 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
// Live per-schedule corridor budget + arm/changed flags, in departure order.
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
// flags, in departure order.
const trains: Array<{
id: string;
budget: CorridorBudget;
stock: WagonStockLedger;
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);
@@ -1841,10 +1872,12 @@ 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);
trains.push({ id, budget, armed: false, changed: false });
const stock = await this.stockLedgerFor(schedule, budget);
trains.push({ id, budget, stock, armed: false, changed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
@@ -1860,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);
@@ -1884,12 +1920,20 @@ export class BookingBatchService implements OnModuleInit {
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
// leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
return (
leg != null &&
t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
);
});
// Per-unit trace: chosen train + each train's remaining room on this leg.
@@ -1934,7 +1978,13 @@ export class BookingBatchService implements OnModuleInit {
// already consumed most of the room). Consolidated pairs / government /
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
const offered = await this.maybeOfferPartial(
booking,
isPair,
trains,
need,
wagonTypeIds,
);
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
@@ -1964,6 +2014,9 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5.
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
target.changed = true;
reservedThisPass += 1;
} catch (err) {
@@ -2027,14 +2080,32 @@ export class BookingBatchService implements OnModuleInit {
private async maybeOfferPartial(
booking: Booking,
isPair: boolean,
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
candidates: Array<{
id: string;
budget: CorridorBudget;
armed: boolean;
stock?: WagonStockLedger;
}>,
need: Capacity,
wagonTypeIds: string[] = [],
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
if (!leg) return null;
const room = c.budget.remainingFor(leg);
// The offer may never exceed the wagons that physically exist in a type
// this booking can ride. This is what turns "20 free wagons, only 16 of
// them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17.
const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg)
: undefined;
const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } };
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2047,6 +2118,7 @@ export class BookingBatchService implements OnModuleInit {
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
target.c.armed = true;
return true;
}
@@ -3204,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) ||
@@ -3468,6 +3595,131 @@ export class BookingBatchService implements OnModuleInit {
return dims.length ? dims : [fallback];
}
/**
* Physical wagon-type stock for one schedule, on the same corridor edges its
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
* batch counts exactly the wagons the allocator will later plan against.
*/
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
}
/**
* Whether the train holds enough PHYSICAL wagons of the types this booking may
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
* abstract budget still governs, and a mis-configured cargo type must not
* silently strand every booking that uses it.
*/
private hasWagonStock(
stock: WagonStockLedger,
wagonTypeIds: string[],
wagonsNeeded: number,
leg: CorridorLeg,
): boolean {
if (!wagonTypeIds.length) return true;
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
}
private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
expiresAt: number;
} | null = null;
/**
* Wagon-type ids each cargo / container type may ride, read straight from the
* join tables.
*
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
* a hot path. So the pool's booking entities carry the type FK but not the
* allowed list, and resolving it per booking through the relation would come
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
* give the same answer without touching the pool query.
*/
private async loadAllowedWagonTypeIds(): Promise<{
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
}> {
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
return this.allowedWagonTypeCache;
}
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
// scheduling service — the allocator will not plan against them either.
const [cargoRows, containerRows]: [
Array<{ typeId: string; wagonTypeId: string }>,
Array<{ typeId: string; wagonTypeId: string }>,
] = await Promise.all([
this.dataSource.query(
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
this.dataSource.query(
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
]);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, string[]>();
for (const row of rows) {
const list = map.get(row.typeId) ?? [];
list.push(row.wagonTypeId);
map.set(row.typeId, list);
}
return map;
};
const value = {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
};
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
* caller must then skip the physical-stock gate rather than block the booking
* on missing configuration.
*/
private allowedWagonTypeIdsFor(
booking: Booking,
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): string[] {
if (booking.freightType === "BULK") {
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
}
const ids = new Set<string>();
for (const line of booking.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
ids.add(id);
}
}
return [...ids];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.

View File

@@ -5,6 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -48,6 +49,7 @@ export class BookingJourneyService {
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -145,6 +147,12 @@ export class BookingJourneyService {
});
});
// Intercity ends here — a ONE_TIME contract closes on its shipment being
// delivered (import/export emit this from booking-transition.complete).
if (nextStatus === 'COMPLETED') {
this.events.emit('booking.completed', { bookingId });
}
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -303,6 +311,12 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
}
return rows.map((r) => r.id);
}
@@ -487,7 +501,13 @@ export class BookingJourneyService {
currentYardId: booking.destinationYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: Freight.WagonStatus.Available,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool. Marking a
// coupled wagon AVAILABLE made it show up in the train-builder's
// "available wagons" picker, where attaching it always 409'd.
status: wagon.trainId
? Freight.WagonStatus.Assigned
: Freight.WagonStatus.Available,
});
}
}

View File

@@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto {
type: [String],
format: 'uuid',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds?: string[];

View File

@@ -48,10 +48,12 @@ export class FacilityHandlingService {
if (!facility?.hasFacility) return null;
const occurredAt = input.occurredAt ?? new Date();
// Mapped to the goods owner, same as every warehouse-raised GRN.
const grnNumber = generateGrnNumber(
booking.tradeDirection ?? 'DOMESTIC',
booking.id,
occurredAt,
booking.company?.name ?? null,
);
// Link the storage record when this facility keeps cargo — that link is

View File

@@ -5,7 +5,7 @@ import {
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
@@ -197,42 +197,75 @@ describe('train-capacity.util', () => {
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('takes the weakest locomotive across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});

View File

@@ -256,27 +256,40 @@ function round3(value: number): number {
}
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
* Effective limits for a train set, per axis:
*
* - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull
* 3500T. Only CONFIGURED pull weights are summed; a set with none configured
* reports Infinity (no opinion), exactly as before.
* - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its
* own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0).
* - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not
* a haulage one: coupling a second locomotive does not lengthen the track, so
* the most restrictive locomotive still governs (and its tolerance with it).
*
* Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
export function combinedLocomotiveLimits(
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
const configuredPulls = locomotives
.map((l) => num(l.maxPullWeightTons))
.filter((v) => v > 0);
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxPullWeightTons: configuredPulls.length
? round3(configuredPulls.reduce((sum, v) => sum + v, 0))
: Infinity,
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
),
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
// tolerance set has no opinion, it does not zero out the others.
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)),
// Paired with the length axis, so it stays the weakest CONFIGURED value — a
// locomotive with no tolerance set has no opinion, it does not zero the others.
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
};
}
@@ -286,10 +299,16 @@ function minConfigured(values: Array<number | null | undefined>): number {
return configured.length ? Math.min(...configured) : 0;
}
function sumConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
* Effective limits for a whole train set: {@link combinedLocomotiveLimits} over
* its linked locomotives, falling back to the legacy single `locomotive` column
* for sets created before multi-loco support. Null when the set has no
* locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
@@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits(
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
return combinedLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */

View File

@@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
@@ -1300,9 +1300,9 @@ export class TrainSchedulingService {
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 2) {
if (locomotiveIds.length < 1) {
throw new BadRequestException(
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
@@ -1323,8 +1323,8 @@ export class TrainSchedulingService {
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
}
@@ -1382,7 +1382,7 @@ export class TrainSchedulingService {
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
@@ -1572,7 +1572,7 @@ export class TrainSchedulingService {
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
@@ -3971,36 +3971,12 @@ export class TrainSchedulingService {
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
stock = await this.builtTrainStock(builtTrainId);
} else {
// Dynamic consist: a slot's physical wagon may ride from the train's origin
// OR already sit at the booking's own boarding yard and attach there — so
// the usable fleet is the union across the origin and every boarding yard.
const boardYardIds = [
...new Set(
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) =>
this.countFleetAvailability(yardId, targetScheduleId),
),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const stock: WagonStock = await this.wagonStockForSchedule(
targetScheduleId,
originYardId,
bookings.map((b) => b.originYardId),
builtTrainId,
);
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
@@ -4129,7 +4105,7 @@ export class TrainSchedulingService {
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
if (offYard) {
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
@@ -4799,6 +4775,51 @@ export class TrainSchedulingService {
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
/**
* The physical wagons a schedule can actually plan against, by wagon type.
*
* A schedule built from a Train Builder train plans against ONLY that train's
* own consist. A legacy/dynamic-consist schedule plans against the boarding
* yards' loose pool: a slot's wagon may ride from the train's origin OR
* already sit at the booking's own boarding yard and attach there, so the
* usable fleet is the union across the origin and every boarding yard.
*
* Public because batch fill needs the SAME stock the allocator will later
* validate against — selecting a booking the allocator cannot place is how
* customers ended up paying for wagons that were never there.
*/
async wagonStockForSchedule(
scheduleId: string | undefined,
originYardId: string,
boardingYardIds: Array<string | null | undefined> = [],
preloadedBuiltTrainId?: string | null,
): Promise<WagonStock> {
const builtTrainId =
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
@@ -5347,7 +5368,7 @@ export class TrainSchedulingService {
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with fewer than two locomotives are omitted (never schedulable).
* Trains with no locomotive at all are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
@@ -5385,7 +5406,7 @@ export class TrainSchedulingService {
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 2)
.filter((train) => (train.locomotives ?? []).length >= 1)
.map((train) => {
const wagons = train.wagons ?? [];
return {
@@ -5417,7 +5438,15 @@ export class TrainSchedulingService {
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: roundTons(Number(train.capacityTons)),
// Live from the coupled set — `capacity_tons` still holds the old
// single-locomotive figure on trains built before pull weight summed.
maxPullWeightTons: roundTons(
combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
)?.maxPullWeightTons ?? Number(train.capacityTons),
),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
@@ -5467,7 +5496,7 @@ export class TrainSchedulingService {
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
@@ -5590,7 +5619,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);

View File

@@ -0,0 +1,70 @@
import { WagonStockLedger } from './wagon-stock-ledger.util';
const WHOLE = { fromEdge: 0, toEdge: 1 };
describe('WagonStockLedger', () => {
it('reports the wagons of a booking\'s OWN types, not the train total', () => {
// The reported case: 20 free wagons on the train, but only 16 of them NW5.
const ledger = new WagonStockLedger(
new Map([
['nw5', 16],
['pw2', 4],
]),
1,
);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16);
expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4);
// A cargo type mapped to both may ride either, so they add up.
expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20);
// Duplicates must not double-count.
expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16);
// An unconfigured type has no stock.
expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0);
});
it('consumes what it can and reports the shortfall', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1);
// A 20-wagon booking can only take 16 — the caller splits on that number.
expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0);
});
it('drains the deepest stock first across candidate types', () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 10],
['nw7', 3],
]),
1,
);
expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12);
// 10 from NW5 then 2 from NW7 — one NW7 left.
expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
});
it('frees stock past an alight yard — disjoint legs never compete', () => {
// Three stops (A→B→C) = two edges. An intercity booking riding A→B must
// not consume the wagon on B→C.
const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2);
const firstLeg = { fromEdge: 0, toEdge: 1 };
const secondLeg = { fromEdge: 1, toEdge: 2 };
ledger.consume(['nw5'], 5, firstLeg);
expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0);
expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5);
// A whole-route booking sees the busiest edge it crosses, so it is blocked.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0);
});
it('counts the busiest edge within a leg, not the sum of edges', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3);
ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 });
ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 });
// Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4);
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});

View File

@@ -0,0 +1,86 @@
import type { CorridorLeg } from './corridor-capacity.util';
/**
* Physical wagon-type stock for one train, consumed per corridor edge.
*
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
* yard" — money taken for space that never existed.
*
* This ledger is the missing axis: how many wagons of the types a booking may
* actually ride are free. Batch fill consults it alongside the budget, so a
* booking is admitted whole only when both agree, and is otherwise offered a
* split sized to the wagons that genuinely exist.
*
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
* an alight yard is available again downstream, so an intercity ride-along on
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
private readonly usedPerEdge = new Map<string, number[]>();
constructor(
private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number,
) {}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = this.usedPerEdge.get(wagonTypeId);
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
busiest = Math.max(busiest, row[edge] ?? 0);
}
return Math.max(0, total - busiest);
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.
*/
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
let total = 0;
for (const id of new Set(wagonTypeIds)) {
total += this.availableForType(id, leg);
}
return total;
}
/**
* Take `wagons` from the candidate types, deepest stock first so the consist
* drains evenly (same tie-break as the wagon planner). Returns how many were
* actually taken — less than asked when the stock is short.
*/
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
let outstanding = Math.max(0, Math.floor(wagons));
const candidates = [...new Set(wagonTypeIds)];
let taken = 0;
while (outstanding > 0) {
const deepest = candidates
.map((id) => ({ id, free: this.availableForType(id, leg) }))
.filter((c) => c.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;
}
outstanding -= take;
taken += take;
}
return taken;
}
}

View File

@@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* pulled by at least one locomotive; `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })

View File

@@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
/** All locomotives pulling this train set (minimum 1). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];

View File

@@ -33,10 +33,10 @@ export class BuildTrainDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
description: 'Locomotives pulling the train (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];

View File

@@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Full replacement locomotive set (minimum 2), in consist order',
description: 'Full replacement locomotive set (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
}

View File

@@ -6,7 +6,7 @@ import { Train } from './train.entity';
/**
* Link row joining a built train to one of its locomotives. A train must be
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
* pulled by at least one locomotive; `sequenceNo` is the order
* in the consist — 0 is the lead locomotive.
*
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built

View File

@@ -80,7 +80,7 @@ export class Train extends BaseEntity {
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[];
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
/** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */
@OneToMany(() => TrainLocomotive, (link) => link.train)
locomotives?: TrainLocomotive[];
}

View File

@@ -62,7 +62,7 @@ export class TrainBuilderController {
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainLocomotivesDto,

View File

@@ -3,6 +3,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
@@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -55,12 +56,14 @@ export interface ActiveScheduleRef {
*/
@Injectable()
export class TrainBuilderService {
private readonly logger = new Logger(TrainBuilderService.name);
constructor(private readonly dataSource: DataSource) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
const trainId = await this.dataSource.transaction(async (manager) => {
@@ -96,7 +99,7 @@ export class TrainBuilderService {
);
// Effective haul capacity is capped by the weakest locomotive in the set.
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
const train = await manager.getRepository(Train).save(
manager.getRepository(Train).create({
code,
@@ -283,7 +286,7 @@ export class TrainBuilderService {
: null,
}));
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
@@ -339,11 +342,11 @@ export class TrainBuilderService {
};
}
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
/** Replace the locomotive set (minimum 1, same-yard rule applies). */
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
@@ -360,7 +363,7 @@ export class TrainBuilderService {
train.id,
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
@@ -520,6 +523,28 @@ export class TrainBuilderService {
sequenceNumber: null,
status: WagonStatus.Maintenance,
});
// Audit row: which train it came off and when. The wagon does not change
// yard here, so from/to are the same — the ledger is the wagon's history
// surface, and a maintenance detach has to be in it.
const yardId = wagon.currentYardId ?? train.currentYardId ?? null;
if (yardId) {
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: yardId,
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
occurredAt: new Date(),
}),
);
} else {
// to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance,
// it just cannot carry a ledger row.
this.logger.warn(
`Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`,
);
}
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
@@ -743,7 +768,16 @@ export class TrainBuilderService {
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: round(train.capacityTons),
// Derived live from the coupled set, NOT from the stored capacity_tons.
// That column is written at build/re-couple time, so every train built
// before pull weight became additive still holds the old single-locomotive
// figure. Computing it here keeps the board honest without a backfill;
// the column self-heals the next time the locomotive set is saved.
maxPullWeightTons: round(
combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ??
Number(train.capacityTons) ??
0,
),
};
}
@@ -878,7 +912,7 @@ export class TrainBuilderService {
where: { trainId: train.id },
relations: { locomotive: true },
});
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTransitAgentDto } from './create-transit-agent.dto';
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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' },
});
}
}

View File

@@ -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);
}
}

View File

@@ -1,7 +1,17 @@
import { WagonStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
export class ListWagonsQueryDto {
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
@@ -29,6 +39,15 @@ export class ListWagonsQueryDto {
@IsUUID()
trainId?: string;
@ApiPropertyOptional({
description:
'Only loose wagons (not coupled to a built train) — what a picker can actually take.',
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === true || value === 'true')
@IsBoolean()
unassigned?: boolean;
@ApiPropertyOptional({
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
})
@@ -53,11 +72,21 @@ export class ListWagonsQueryDto {
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
@Max(100)
pageSize?: number;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -232,4 +232,29 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('listRequests', () => {
// TypeORM paginates a joined query through a DISTINCT subquery and resolves
// every orderBy criterion against entity metadata — a DB column name there
// (`r.created_at`) makes it read `.databaseName` of undefined → 500.
it('sorts by the entity property path, not the DB column', async () => {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
requestRepo.createQueryBuilder.mockReturnValue(qb);
await service.listRequests({
status: 'PENDING,PARTIALLY_FULFILLED',
page: 1,
pageSize: 10,
});
expect(qb.orderBy).toHaveBeenCalledWith('r.createdAt', 'DESC');
});
});
});

View File

@@ -114,6 +114,8 @@ export class WagonTransferRequestsService {
currentYardId: yardId,
wagonTypeId,
status: WagonStatus.Available,
// Coupled to a built train = not movable; bulkTransfer rejects it too.
trainId: IsNull(),
},
});
}
@@ -159,7 +161,7 @@ export class WagonTransferRequestsService {
? 'r.quantity'
: query.sortBy === 'status'
? 'r.status'
: 'r.created_at';
: 'r.createdAt';
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
@@ -399,6 +401,7 @@ export class WagonTransferRequestsService {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: WagonStatus.Available,
trainId: IsNull(),
},
order: { wagonNumber: 'ASC' },
take: remaining,

View File

@@ -39,7 +39,9 @@ export class WagonsController {
@Get()
@StaffReference()
@ApiOperation({ summary: 'List all wagons' })
@ApiOperation({
summary: 'List wagons, paginated ({items, meta}) — 10 per page by default',
})
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}

View File

@@ -1,4 +1,4 @@
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
Injectable,
@@ -6,7 +6,8 @@ import {
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -42,7 +43,8 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
const search = query.search?.trim();
const trainId = query.trainId?.trim();
const wagonTypeId = query.wagonTypeId?.trim();
@@ -61,6 +63,9 @@ export class WagonsService {
if (query.currentYardId)
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
// Pickers (train-builder, transfer fulfilment) can only take a wagon that is
// not already coupled to a built train — never offer one the API will reject.
if (query.unassigned) qb.andWhere('w.trainId IS NULL');
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
// Filter by run: the odd export run identifies the pair, so match either
@@ -72,6 +77,18 @@ export class WagonsService {
);
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (query.createdFrom) {
qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: query.createdFrom,
});
}
if (query.createdTo) {
qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: query.createdTo,
});
}
// Search matches the wagon number or either run number.
if (search) {
qb.andWhere(
@@ -95,12 +112,16 @@ export class WagonsService {
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
qb.orderBy(`w.${sortBy}`, sortOrder);
if (query.page && query.limit) {
qb.skip((Number(query.page) - 1) * Number(query.limit));
}
if (query.limit) qb.take(Number(query.limit));
return qb;
}
return qb.getMany();
/**
* The wagon list is always a page. Callers that genuinely need every row
* (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice.
*/
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
}
async findById(id: string): Promise<Wagon> {

View File

@@ -1,7 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
import {
WAREHOUSE_YARD_DIRECTIONS,
WAREHOUSE_YARD_TYPES,
WarehouseYardDirection,
WarehouseYardType,
} from '../entities/warehouse-yard.entity';
export class CreateWarehouseYardDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
@IsNumber()
@Min(0)
maxVolume?: number;
@ApiPropertyOptional({
enum: WAREHOUSE_YARD_DIRECTIONS,
description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.',
})
@IsOptional()
@IsEnum(WAREHOUSE_YARD_DIRECTIONS)
direction?: WarehouseYardDirection;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
cargoTypeIds?: string[];
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Warehouse } from './warehouse.entity';
import { WarehouseZone } from './warehouse-zone.entity';
@@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
/**
* Which trade direction this yard serves. Only meaningful for CONTAINER_YARD,
* where import and export stacks are physically separate areas (e.g. Indode's
* Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo
* either way, so BOTH/null is the right default there.
*/
export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number];
@Entity({ schema: 'freight', name: 'warehouse_yards' })
@Index(['warehouseId'])
@Index(['type'])
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
/** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: WarehouseYardDirection | null;
/**
* Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck,
* Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any
* cargo type of the yard's structural `type` (the pre-existing behavior),
* so this is additive and never blocks a yard that hasn't been configured.
*/
@ManyToMany(() => CargoType)
@JoinTable({
name: 'warehouse_yard_cargo_types',
schema: 'freight',
joinColumn: { name: 'yard_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
})
cargoTypes?: CargoType[];
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
zones?: WarehouseZone[];
}

View File

@@ -0,0 +1,82 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Detention is per truck: two trucks on the same delivery with different
* windows must produce different chargeable days and amounts (the old
* leg-level clock billed them identically).
*/
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeTruckDetention: (
rule: Record<string, unknown> | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
now: Date,
billingCurrency: string,
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
calculateTieredAmount: unknown;
};
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
// 3h grace, 50/truck/day, no tiers.
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
const now = new Date('2026-07-25T12:00:00Z');
describe('per-truck detention', () => {
it('bills each truck on its own window', async () => {
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
const a = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
const b = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
expect(a.chargeableDays).toBe(2);
expect(b.chargeableDays).toBe(4);
expect(a.amount).toBe(100);
expect(b.amount).toBe(200);
// The whole point: same delivery, different bills.
expect(a.amount).not.toBe(b.amount);
});
it('charges nothing inside the grace window', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
now,
'USD',
);
expect(out.chargeableDays).toBe(0);
expect(out.amount).toBe(0);
});
it('keeps accruing against now when a truck has not returned', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
now,
'USD',
);
expect(out.endIsOpen).toBe(true);
expect(out.chargeableDays).toBe(2);
});
});

View File

@@ -17,6 +17,8 @@ export interface ImportTrainRow {
route: string | null;
origin: string | null;
destination: string | null;
/** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */
destinationStationId: string | null;
arrivalTime: string | null;
totalBookings: number;
totalContainers: number;
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
cargoTypeCode: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
@@ -280,6 +285,7 @@ export class SchedulingReadFacade {
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
cgt.code AS "cargoTypeCode",
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
dy.label AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",

View File

@@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => {
inventoryWeight: 25,
bookingContainerCount: 0,
cargoUnitOfMeasure: null,
// Double handling now bills only when staff answered Yes after unloading;
// these quantity-basis cases assume that answer (the gate itself is covered
// in double-handling-gate.spec.ts).
doubleHandling: true,
facilityId: null,
warehouseId: null,
yardId: null,

View File

@@ -92,10 +92,20 @@ export interface FeePreview {
ratePerDay: number;
amount: number;
}>;
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
/**
* Truck detention: one row PER TRUCK — each truck has its own detention
* window (it arrives and is released at its own time) and its own matching
* rule by truck type, so days and amount differ between trucks.
*/
groups?: Array<{
assignmentId: string | null;
vehicleId: string | null;
plateNumber: string | null;
vehicleType: string | null;
truckCount: number;
startDate: string | null;
endDate: string | null;
endIsOpen: boolean;
chargeableDays: number;
ratePerDay: number;
amount: number;
@@ -827,25 +837,48 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
// One row PER TRUCK: each truck has its own detention window (it reaches the
// destination and is released at its own time) and resolves its own rule by
// CANONICAL truck type — the truck_types FK is the source of truth, with the
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
// legs recorded before per-truck tracking.
const truckRows: Array<{
assignmentId: string;
vehicleId: string;
plateNumber: string | null;
vehicleType: string | null;
startAt: Date | string | null;
endAt: Date | string | null;
}> = await this.dataSource.query(
`SELECT va.id AS "assignmentId",
va.vehicle_id AS "vehicleId",
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
ORDER BY va.created_at ASC`,
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
);
// No trucks assigned yet: keep the leg-level single-truck estimate so the
// preview still tells the operator what detention would cost.
const trucks = truckRows.length
? truckRows
: [
{
assignmentId: null as string | null,
vehicleId: null as string | null,
plateNumber: null as string | null,
vehicleType: null as string | null,
startAt: leg.arrivedAt ?? null,
endAt: leg.deliveredAt ?? null,
},
];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
@@ -853,7 +886,7 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const computed = await Promise.all(
groups.map(async (g) => {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
gateClearedAt: null,
@@ -862,7 +895,7 @@ export class WarehouseFeeService {
tradeDirection: leg.tradeDirection ?? null,
cargoTypeCode: null,
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
vehicleType: t.vehicleType ?? null,
inventoryQuantity: 1,
inventoryWeight: 0,
bookingContainerCount: 1,
@@ -875,37 +908,47 @@ export class WarehouseFeeService {
zoneId: null,
};
const rule = this.bestRule(detentionRules, item);
// truckCount 1 — this row IS one truck.
const c = await this.computeTruckDetention(
rule,
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
now,
billingCurrency,
);
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
return { ...t, c };
}),
);
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
const totalTrucks = computed.length;
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
// Header days: the worst truck — a single number can't represent per-truck
// windows, and the longest detention is the one operations must act on.
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
const single = computed.length === 1 ? computed[0].c : null;
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
const earliestStart = computed
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
.filter((n): n is number => n != null)
.sort((a, b) => a - b)[0];
const anyOpen = computed.some((x) => x.c.endIsOpen);
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
freeDays: 0,
ratePerDay: single?.ratePerDay ?? 0,
currency: targetCurrency,
ruleCurrency: single?.ruleCurrency ?? null,
billingCurrency: targetCurrency,
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
endIsOpen: !leg.deliveredAt,
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
endDate: (anyOpen ? now : new Date(Math.max(
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
))).toISOString(),
endIsOpen: anyOpen,
elapsedDays: chargeableDays,
chargeableDays,
containerCount: totalTrucks,
@@ -913,8 +956,14 @@ export class WarehouseFeeService {
amount: totalAmount,
tiers: single ? single.tiers : [],
groups: computed.map((x) => ({
assignmentId: x.assignmentId,
vehicleId: x.vehicleId,
plateNumber: x.plateNumber,
vehicleType: x.vehicleType,
truckCount: x.truckCount,
truckCount: 1,
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
endDate: x.c.endDate,
endIsOpen: x.c.endIsOpen,
chargeableDays: x.c.chargeableDays,
ratePerDay: x.c.ratePerDay,
amount: x.c.amount,

View File

@@ -1141,6 +1141,8 @@ export class WarehouseInventoryService {
async autoUnloadArrived(): Promise<AutoUnloadResult> {
const arrived: {
id: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
@@ -1158,10 +1160,12 @@ export class WarehouseInventoryService {
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
[this.ARRIVED_BOOKING_STATUSES],
);
@@ -1198,7 +1202,7 @@ export class WarehouseInventoryService {
status: 'RECEIVED',
arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT'
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) }
: {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
@@ -1223,12 +1227,19 @@ export class WarehouseInventoryService {
// A GRN is the receipt for cargo entering the warehouse, so every booking
// gets one on unload — import as well as export. The direction only decides
// the GRN prefix, not whether one is issued.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
// The GRN is mapped to the goods owner (the booking's company), so pull it
// alongside the direction rather than issuing an owner-less number.
const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
await this.dataSource.query(
`SELECT b.trade_direction AS "tradeDirection",
company.name AS "ownerName"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
const ownerName = bookingRow?.ownerName ?? null;
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1252,7 +1263,7 @@ export class WarehouseInventoryService {
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
...(existing[0].grnNumber
? {}
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1267,7 +1278,7 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1577,7 +1588,7 @@ export class WarehouseInventoryService {
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined;
@@ -2143,6 +2154,8 @@ export class WarehouseInventoryService {
const bookings: {
id: string;
status: string;
/** Goods owner (company) — the GRN number is mapped to it. */
customer: string | null;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
@@ -2165,10 +2178,12 @@ export class WarehouseInventoryService {
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId],
@@ -2235,7 +2250,7 @@ export class WarehouseInventoryService {
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload.
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }),
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
@@ -2285,7 +2300,7 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2719,7 +2734,12 @@ export class WarehouseInventoryService {
this.assertCapacity('Zone', zone, weight, volume, containerCount);
const now = new Date();
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
const grnNumber = this.generateGrnNumber(
bookingDirection ?? 'WH',
dto.bookingId ?? 'MANUAL',
now,
truckEntrance?.ownerName ?? bookingSource?.customer,
);
const receiveNote = this.buildReceiveNote({
grnNumber,
notes: dto.notes?.trim() || 'Single booking received',
@@ -3659,6 +3679,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}>
> {
const rows: Array<{
@@ -3676,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,
@@ -3690,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
@@ -3742,6 +3765,7 @@ export class WarehouseInventoryService {
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
inspectionStatus: r.inspectionStatus,
handoverSigned,
}));
}
@@ -5350,7 +5374,9 @@ export class WarehouseInventoryService {
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
// The GRN is mapped to the owner (import: consignee, export: shipper) —
// named explicitly so the note reads the same for both directions.
["Owner's Name", data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
@@ -5981,8 +6007,13 @@ export class WarehouseInventoryService {
}
/** Shared with the facility handling flow — see common/grn.util.ts. */
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
return generateGrnNumber(direction, referenceId, date);
private generateGrnNumber(
direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
return generateGrnNumber(direction, referenceId, date, ownerName);
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {

View File

@@ -1,8 +1,9 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeepPartial, Repository } from 'typeorm';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@Injectable()
@@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
super(repository);
}
/** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */
async update(id: string, data: DeepPartial<WarehouseYard>): Promise<WarehouseYard | null> {
const { cargoTypes, ...columns } = data;
if (Object.keys(columns).length) {
await this.repository.update(id, columns as never);
}
if (cargoTypes) {
const entity = await this.repository.findOne({ where: { id } as never });
if (entity) {
entity.cargoTypes = cargoTypes as CargoType[];
await this.repository.save(entity);
}
}
return this.findById(id);
}
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -15,7 +16,7 @@ export class WarehouseYardsService {
findAll(): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
relations: { warehouse: true, zones: true },
relations: { warehouse: true, zones: true, cargoTypes: true },
order: { code: 'ASC' },
});
}
@@ -23,14 +24,14 @@ export class WarehouseYardsService {
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
where: { warehouseId },
relations: { zones: true },
relations: { zones: true, cargoTypes: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<WarehouseYard> {
const yard = await this.yardsRepository.findById(id, {
relations: { warehouse: true, zones: true },
relations: { warehouse: true, zones: true, cargoTypes: true },
});
if (!yard) {
@@ -51,6 +52,7 @@ export class WarehouseYardsService {
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
direction: dto.direction ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
@@ -60,6 +62,8 @@ export class WarehouseYardsService {
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType),
});
}
@@ -84,12 +88,16 @@ export class WarehouseYardsService {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
direction: dto.direction ?? existing.direction,
capacityWeight: newCapacityWeight,
capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
...(dto.cargoTypeIds
? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) }
: {}),
});
if (!updated) {