mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
Merge pull request #982 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -1032,7 +1092,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 +1152,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 +1189,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 +1227,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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,13 +113,10 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
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 +124,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;
|
||||
}
|
||||
|
||||
@@ -352,11 +349,10 @@ 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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -720,7 +716,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 +729,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, {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,20 @@ 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): GL initiates on the customer's behalf, the customer
|
||||
* uploads the GL-input documents on the instance, GL approves them and runs
|
||||
* the phased ET/DJ workflow (pre-booking milestones are seeded 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 +414,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 +432,27 @@ export class ContractBookingService {
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
// Customs (Path B): GL initiates on the customer's behalf — assertGate
|
||||
// rejects anyone else. Self-clearance (Path A): the customer initiates.
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
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 +497,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 +722,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 +830,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 +896,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 +976,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}: ${
|
||||
@@ -1001,32 +992,27 @@ export class ContractBookingService {
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): 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.
|
||||
// Path B — Global Logistics initiates and completes the booking ON BEHALF
|
||||
// OF the customer. The customer never books a customs contract himself;
|
||||
// he only uploads documents on the instance GL opened for him.
|
||||
if (!isGlActor) {
|
||||
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';
|
||||
@@ -1041,6 +1027,40 @@ export class ContractBookingService {
|
||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
/**
|
||||
* GL worklist: executed ONE_TIME customs contracts with no live shipment
|
||||
* instance yet. Customs contracts are initiated by GL on the customer's
|
||||
* behalf, so without this list a signed contract would sit with nothing on any
|
||||
* queue (clearance lives on the booking, and the booking does not exist yet).
|
||||
* GENERAL customs is excluded — its instances are 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 +1438,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
|
||||
|
||||
@@ -34,7 +34,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';
|
||||
|
||||
@@ -1060,46 +1060,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> {
|
||||
@@ -1693,80 +1653,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -1262,43 +1275,15 @@ 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): GL initiates the booking, the customer 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 +1293,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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -453,6 +456,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' })
|
||||
@@ -924,31 +986,18 @@ 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);
|
||||
@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();
|
||||
}
|
||||
|
||||
// ── 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 +1066,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). Customs contracts are initiated by GL Ethiopia.',
|
||||
})
|
||||
initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -1335,7 +1384,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
@@ -217,6 +219,14 @@ export class Contract extends BaseEntity {
|
||||
@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 +353,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
OverageTolerance,
|
||||
stopYardsFor,
|
||||
} from './corridor-capacity.util';
|
||||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
@@ -1619,6 +1620,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");
|
||||
@@ -1655,14 +1658,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 +1685,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 +1715,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,11 +1836,14 @@ 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;
|
||||
}> = [];
|
||||
@@ -1844,7 +1860,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);
|
||||
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 };
|
||||
|
||||
@@ -1884,12 +1901,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 +1959,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 +1995,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 +2061,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 +2099,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;
|
||||
}
|
||||
@@ -3468,6 +3521,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -72,16 +72,7 @@ export class ListWagonsQueryDto {
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
|
||||
/** Page size for `GET /wagons/paged`; the legacy `limit` still drives `GET /wagons`. */
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -39,19 +39,13 @@ 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);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('paged')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List wagons, paginated ({items, meta})' })
|
||||
findAllPaged(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAllPaged(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a wagon by ID' })
|
||||
|
||||
@@ -115,20 +115,13 @@ export class WagonsService {
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const qb = this.buildListQuery(query);
|
||||
|
||||
if (query.page && query.limit) {
|
||||
qb.skip((Number(query.page) - 1) * Number(query.limit));
|
||||
}
|
||||
if (query.limit) qb.take(Number(query.limit));
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/** Same filters as `findAll`, on the shared `{items, meta}` list envelope. */
|
||||
findAllPaged(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
return paginateQuery(this.buildListQuery(query), query);
|
||||
/**
|
||||
* 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> {
|
||||
|
||||
Reference in New Issue
Block a user