Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

View File

@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
/** Normalize and validate booking freight shape (used on create and after update merge). */
@@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void {
}
//
const condition = input.cargoCondition ?? 'LADEN';
if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) {
throw new BadRequestException(
`cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`,
);
}
const containers = input.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType = Boolean(input.cargoTypeId);
// Empty means bare equipment: there is no commodity to name, and bulk has no
// equipment of its own to move, so EMPTY only ever rides CONTAINER freight.
if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') {
throw new BadRequestException(
'An empty booking must be CONTAINER freight — bulk carries no equipment',
);
}
if (input.freightType === 'BULK') {
if (hasContainers) {
throw new BadRequestException(

View File

@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Operation request needs changes', msg);
}
/**
* Operations moved the shipment day (and possibly the train) themselves
* instead of asking the customer to. The booking stays under review, so the
* customer only needs to know the new day — nothing to resubmit.
*/
operationRescheduled(b: Booking, previousDay: string | null, note?: string): void {
const newDay = b.scheduledDate
? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' })
: 'a new day';
const msg =
`Operations moved the shipment day of booking ${b.reference} ` +
`${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` +
(note ? ` Note from Operations: ${note}` : '') +
' The request stays under review — no action is needed on your side.';
if (b.customsClearingEnabled) {
this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients:
b.createdByRole === 'GL_ET' && b.createdByUserId
? { userIds: [b.createdByUserId] }
: CLEARANCE_DESK,
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} shipment day changed`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note: note ?? null },
});
return;
}
void this.notifyContact(b, msg, 'OPERATION RESCHEDULED');
this.inApp(b, 'Shipment day changed by Operations', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
// No invoice and no pay window for a shipping line — the charge sits on

View File

@@ -779,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
expect(line.amount).toBe(3 * 1690);
});
});
/**
* Empty container import is bare equipment moved as freight in its own right.
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
* rate for the same lane and box — the two are separate tariffs, and
* UQ_rates_pattern only lets both exist because the rateType differs.
*/
describe('BookingPricingService — empty container import', () => {
const DJIBOUTI = 'yard-djibouti';
const CT40 = 'ct-40ft';
const ladenImport40: Rate = {
id: 'rate-container-import-40',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 900,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
const emptyImport40: Rate = {
id: 'rate-empty-container-import-40',
rateType: 'EMPTY_CONTAINER_IMPORT',
currency: 'USD',
rateValue: 250,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
let service: BookingPricingService;
const priceLines = (booking: Booking) =>
(
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
) => Promise<{
lineItems: Array<{ code: string; amount: number; description: string }>;
blocked: string[];
}>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
});
const bookingWith = (cargoCondition: string) =>
({
id: 'b-empty-1',
freightType: 'CONTAINER',
cargoCondition,
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
// Bare equipment declares no VGM — the service zeroes it at create.
cargoTotalWeightVgm: 0,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
const exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
};
service = new BookingPricingService(
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
it('prices an empty booking off the empty tariff, not the laden one', async () => {
const result = await priceLines(bookingWith('EMPTY'));
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(250 * 4);
expect(result.lineItems[0].description).toContain('empty');
});
it('leaves laden bookings on the laden tariff', async () => {
const result = await priceLines(bookingWith('LADEN'));
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(900 * 4);
});
it('treats a booking with no condition set as laden', async () => {
const booking = bookingWith('LADEN');
delete (booking as unknown as Record<string, unknown>).cargoCondition;
const result = await priceLines(booking);
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
});
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
(
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
const result = await priceLines(bookingWith('EMPTY'));
// Never silently fall through to the laden rate — that would bill an empty
// repositioning move at 900/box instead of 250.
expect(result.lineItems).toHaveLength(0);
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
});
});

View File

@@ -249,8 +249,10 @@ export class BookingPricingService {
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
// wagon). Frozen contract snapshots win over live rates; a customs booking
// with nothing configured hard-blocks — clearance never ships for free.
// An empty box carries no declaration and no duty, so there is no clearance
// to sell even if a customs-bundled service type was somehow selected.
const clearanceBlocked: string[] = [];
if (booking.customsClearingEnabled) {
if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') {
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
for (const line of clearance.lineItems) {
lineItems.push(line);
@@ -575,9 +577,17 @@ export class BookingPricingService {
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
const isBulk = booking.freightType === 'BULK';
// Bare equipment prices off its own tariff. It has to be a distinct
// rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on
// rate_type without applies_to, so an empty 40ft rate on a lane would
// collide with the laden 40ft rate for that same lane.
const isEmpty = booking.cargoCondition === 'EMPTY';
const rateType =
booking.tradeDirection === 'IMPORT'
const rateType = isEmpty
? booking.tradeDirection === 'EXPORT'
? 'EMPTY_CONTAINER_EXPORT'
: 'EMPTY_CONTAINER_IMPORT'
: booking.tradeDirection === 'IMPORT'
? isBulk
? 'BULK_IMPORT'
: 'CONTAINER_IMPORT'
@@ -651,7 +661,7 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`,
amount,
unitAmount,
unit: rateUnit,

View File

@@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () =
);
});
});
/**
* Staff reschedule of an operation request: instead of returning the booking
* to the customer, Operations sets the new shipment day (and the export train)
* themselves. Same day-pool / export gates as the customer request; the booking
* lands (back) at OPERATION_REQUEST_PENDING and the customer is told.
*/
describe('BookingTransitionService — staff reschedule of an operation request', () => {
function makeService(over: {
status?: string;
tradeDirection?: 'EXPORT' | 'IMPORT';
hasDeparture?: boolean;
} = {}) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: over.status ?? 'OPERATION_REQUEST_PENDING',
tradeDirection: over.tradeDirection ?? 'IMPORT',
originYardId: 'o-1',
destinationYardId: 'd-1',
totalAmount: 1000,
contractId: null,
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
requestedTrainScheduleId: null,
serviceType: { code: 'RAIL_CONTAINER' },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({
hasDeparture: over.hasDeparture ?? true,
hasCompatible: true,
}),
};
const bookingBatchService = {
pickExportSchedule: jest.fn().mockResolvedValue('sched-1'),
};
const notifier = { operationRescheduled: jest.fn() };
const clearanceEvents = { record: jest.fn() };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
clearanceEvents as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents };
}
it('refuses a booking that has not requested operation', async () => {
const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('refuses a day with no departure on the route and changes nothing', async () => {
const { service, bookingsRepository } = makeService({ hasDeparture: false });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => {
const { service, bookingsRepository, notifier, clearanceEvents } = makeService();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9', // ignored for import — the batch engine assigns the train
'staff-1',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: new Date('2026-07-20T00:00:00.000Z'),
requestedTrainScheduleId: null,
});
expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled();
expect(clearanceEvents.record).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
action: 'OPERATION_RESCHEDULED',
actorType: 'STAFF',
actorId: 'staff-1',
metadata: expect.objectContaining({
previousScheduledDate: '2026-07-01',
scheduledDate: '2026-07-20',
}),
}),
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.objectContaining({ id: 'b-1' }),
'2026-07-01',
undefined,
);
});
it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => {
const { service, bookingsRepository, notifier } = makeService({
status: 'OPERATION_CHANGES_REQUESTED',
});
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
null,
'staff-1',
{ note: ' Moved to the Monday train ' },
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'Moved to the Monday train',
'STAFF_NOTE',
'staff-1',
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.anything(),
'2026-07-01',
'Moved to the Monday train',
);
});
it('export rail: requires the train and persists the pick after the space gate', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService({
tradeDirection: 'EXPORT',
});
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toThrow(/select a train/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9',
'staff-1',
);
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith(
expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }),
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
status: 'OPERATION_REQUEST_PENDING',
requestedTrainScheduleId: 'sched-9',
}),
);
});
});

View File

@@ -1311,6 +1311,45 @@ export class BookingTransitionService {
);
}
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
opts?.bypassDayPool,
);
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
}
/**
* Validate a shipment day (and, for export rail, the picked train) for a
* booking the way the customer's operation request does, and resolve what
* gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId`
* (export rail / shipping-line only — import and domestic trains are assigned
* by the batch engine, so their pick is dropped). Shared by the customer
* request and the staff reschedule so both enforce the same gates.
*/
private async resolveOperationDay(
booking: Booking,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
bypassDayPool?: boolean,
): Promise<{ date: Date; requestedId: string | null }> {
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
@@ -1322,7 +1361,7 @@ export class BookingTransitionService {
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
if (!opts?.bypassDayPool) {
if (!bypassDayPool) {
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
isExportTrain || bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
@@ -1403,21 +1442,76 @@ export class BookingTransitionService {
}
}
return { date, requestedId };
}
/**
* Operations changes the shipment day and/or train of a booking the customer
* has already requested operation on — instead of bouncing it back to the
* customer with a change request, staff set the new day (and, for export
* rail, the train) themselves. The same day-pool / export-space gates as the
* customer's own request apply, so staff cannot park a booking on a day with
* no departure or a train with no room.
*
* Allowed at OPERATION_REQUEST_PENDING (staff review) and at
* OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either
* way the booking lands back at OPERATION_REQUEST_PENDING for the normal
* accept. The customer is told the new day, with the staff note when given.
*/
async rescheduleOperationRequest(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId: string | null | undefined,
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
]);
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
);
const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
const previousTrainId = booking.requestedTrainScheduleId ?? null;
const note = options.note?.trim() || undefined;
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
if (note) {
await this.bookingsRepository.createReviewNote(
bookingId,
note,
"STAFF_NOTE",
actorId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
action: "OPERATION_RESCHEDULED",
label:
`Operations moved the shipment day ` +
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
(requestedId && requestedId !== previousTrainId ? " and changed the train" : ""),
actorType: "STAFF",
actorId,
metadata: {
previousScheduledDate: previousDay,
scheduledDate: eatDay(date),
previousTrainScheduleId: previousTrainId,
trainScheduleId: requestedId,
note: note ?? null,
},
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
this.notifier.operationRescheduled(fresh, previousDay, note);
return fresh;
}

View File

@@ -82,6 +82,7 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
RescheduleOperationDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
@@ -1344,6 +1345,29 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/operation/reschedule")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Operations changes a pending operation request's shipment day and/or " +
"train on the customer's behalf (OPERATION_REQUEST_PENDING | " +
"OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async rescheduleOperationRequest(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RescheduleOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rescheduleOperationRequest(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
resolveAuthUserId(user),
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({

View File

@@ -1106,11 +1106,13 @@ ${footer}
const containers = await Promise.all(
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
// Optional on the DTO — an empty booking states no VGM at all.
const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0);
const totalVgmTons = c.quantity * vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
@@ -1375,13 +1377,25 @@ ${footer}
}
}
const containers = dto.containers ?? [];
const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
assertFreightShape({
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.cargoTypeId,
containers,
containers: dto.containers ?? [],
});
// Bare equipment declares no VGM. Zero the lines HERE, before the rule
// engine sees them, so weight-limit and overweight evaluation, the wagon
// estimate, the persisted rows and every tonnage aggregate downstream all
// read the same figure — a stray VGM on an empty line would otherwise price
// an overweight surcharge on a box with nothing in it.
const containers = (dto.containers ?? []).map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
@@ -1506,10 +1520,11 @@ ${footer}
destinationYardId: dto.destinationYardId,
tradeDirection,
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
@@ -1647,6 +1662,11 @@ ${footer}
const warnings: string[] = [];
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
// A draft may be switched between laden and empty; an untouched draft keeps
// whatever it was created as.
const cargoCondition =
(dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
let containers =
dto.containers ??
(existing.bookingContainers ?? [])
@@ -1672,7 +1692,14 @@ ${footer}
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
// Same normalisation as create: zero the VGM of an empty booking before the
// rule engine, the wagon estimate or the persisted rows ever read it.
containers = containers.map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
@@ -1719,6 +1746,9 @@ ${footer}
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoCondition,
// Bare equipment declares no VGM, whichever way the draft was edited.
cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
@@ -1825,10 +1855,12 @@ ${footer}
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
// Index-aligned with ruleResult, which evaluated these same lines.
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
// Bare equipment declares no VGM — same normalisation the rule engine saw.
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],

View File

@@ -18,7 +18,12 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
CARGO_CONDITIONS,
FREIGHT_TYPES,
} from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -47,11 +52,20 @@ export class CreateBookingContainerDto {
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
/**
* Omitted on an empty booking — bare equipment has no verified gross mass to
* declare, and the service zeroes the line rather than trusting a stray value.
*/
@ApiPropertyOptional({
description: 'VGM per container in tons. Omit for an EMPTY booking',
minimum: 0,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
@Transform(({ value }) => Number(value ?? 0))
vgmPerUnitTons?: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
@@ -312,6 +326,20 @@ export class CreateBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType!: string;
/**
* LADEN (default) or EMPTY. EMPTY is container freight carrying nothing —
* the box itself is the shipment, priced per size and lane off an
* EMPTY_CONTAINER_IMPORT rate.
*/
@ApiPropertyOptional({
enum: CARGO_CONDITIONS,
default: 'LADEN',
description: 'EMPTY moves bare equipment; requires CONTAINER freight',
})
@IsOptional()
@IsIn([...CARGO_CONDITIONS])
cargoCondition?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
@@ -330,10 +358,14 @@ export class CreateBookingDto {
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@ApiProperty({
description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking',
minimum: 0,
})
@ValidateIf((o) => o.cargoCondition !== 'EMPTY')
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
@Transform(({ value }) => Number(value ?? 0))
cargoTotalWeightVgm!: number;
/**

View File

@@ -107,6 +107,38 @@ export class RequestOperationDto {
trainScheduleId?: string;
}
/**
* Operations changes a pending operation request's shipment day and/or train
* on the customer's behalf (instead of returning it for changes).
*/
export class RescheduleOperationDto {
@ApiProperty({
description:
'The new shipment day (train departure day). ISO date — must have an ' +
'open departure on the booking route that can carry the cargo.',
example: '2026-07-15',
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the train (schedule id) to ride, from ' +
'GET /bookings/:id/export-trains for the new day. Required for export ' +
'rail; ignored for import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({
description: 'Optional note to the customer explaining the change.',
})
@IsOptional()
@IsString()
note?: string;
}
export class OperationReviewDto {
@ApiProperty({
description:

View File

@@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
export interface BookingFreightShapeInput {
freightType?: string;
/** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */
cargoCondition?: string | null;
cargoTypeId?: string | null;
containers?: Array<{ containerTypeId?: string }> | null;
}
@@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
return true;
}
// Bulk carries no equipment of its own, so an empty booking is always
// container freight. Rejected here as well as in assertFreightShape so the
// 400 names the field instead of surfacing from the service layer.
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return false;
}
const containers = dto.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType =
@@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
defaultMessage(args: ValidationArguments): string {
const dto = args.object as BookingFreightShapeInput;
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return 'An empty booking must be CONTAINER freight — bulk carries no equipment';
}
if (dto.freightType === 'BULK') {
return 'BULK freight requires cargoTypeId and must not include container lines';
}

View File

@@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
/**
* Whether the booking moves cargo or bare equipment. EMPTY is container
* freight with nothing inside: the box IS the shipment, priced per size and
* lane off an EMPTY_CONTAINER_IMPORT rate.
*
* This is deliberately NOT a third `freightType`. An empty booking is still
* CONTAINER freight everywhere it matters physically — wagon footprint, yard
* and warehouse allocation, train scheduling, marshalling, gate passes — and
* `freightType` is read in ~880 places whose else-arm means "container". Only
* pricing, documents, customs and the contract template branch on condition.
*/
export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
export type CargoCondition = (typeof CARGO_CONDITIONS)[number];
export const SCHEDULING_STATUSES = [
SchedulingStatus.NotScheduled,
SchedulingStatus.Holding,
@@ -388,6 +402,13 @@ export class Booking extends BaseEntity {
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
freightType!: string;
/**
* LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY
* on CONTAINER freight — bulk has no equipment to move on its own.
*/
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
cargoCondition!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;

View File

@@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => {
it('only ever resolves to a code that exists', () => {
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
const conditions = ['LADEN', 'EMPTY', null, undefined];
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
for (const e of [true, false, undefined]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e),
);
for (const cond of conditions) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e, cond),
);
}
}
}
}
}
});
// Empty equipment is a carriage agreement, not a cargo contract: no cargo
// liability, no VGM declaration, no commercial documents, no customs leg.
it('gives empty container import its own customs-free paper', () => {
for (const customs of [true, false]) {
for (const ethiopian of [true, false, undefined]) {
expect(
contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'),
).toBe('IMPORT_EMPTY_CONTAINER');
}
}
});
it('leaves laden contracts on the laden codes', () => {
expect(
contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'),
).toBe('IMPORT_CONTAINER_NO_CUSTOMS');
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
});
// Empty rates and empty bookings are import-only, so a stray EMPTY on any
// other direction must fall through rather than resolve a template that
// describes a Djibouti-to-Ethiopia movement.
it('ignores the empty condition outside import', () => {
expect(
contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'),
).toBe('EXPORT_CONTAINER_NO_CUSTOMS');
expect(
contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'),
).toBe('INTERCITY_CONTAINER');
});
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the fourteen declared codes, once each', () => {
it('seeds exactly the fifteen declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(14);
expect(seeded).toHaveLength(15);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});

View File

@@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
// Carriage of the equipment itself — no cargo, no clearing, so it previews
// against the transport-only scope like every other non-customs code.
IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
};
@Injectable()
@@ -216,6 +219,7 @@ export class ContractTemplatesService {
customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
ethiopianCustomsOnly?: boolean | null,
cargoCondition?: string | null,
): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
@@ -235,6 +239,7 @@ export class ContractTemplatesService {
freightType,
customsClearingEnabled,
ethiopianCustomsOnly,
cargoCondition,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;

View File

@@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
/**
* Empty container import — bare equipment railed north from Djibouti. No
* customs split: an empty box carries no declaration to clear, the same
* reason intercity has a single unsuffixed code. Import-only, matching the
* rate rule (southbound empties are served by the WITH_RETURN surcharge and
* empty_return_requests instead).
*/
"IMPORT_EMPTY_CONTAINER",
] as const;
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
@@ -74,7 +82,14 @@ export function contractTemplateCodeFor(
freightType?: string | null,
customsClearingEnabled?: boolean | null,
ethiopianCustomsOnly?: boolean | null,
cargoCondition?: string | null,
): ContractTemplateCode {
// Empty equipment is its own paper: a straight carriage agreement with no
// cargo liability, no VGM declaration and no customs leg. Import-only, so
// anything else falls through to the laden codes below.
if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") {
return "IMPORT_EMPTY_CONTAINER";
}
const direction =
tradeDirection === "IMPORT"
? "IMPORT"

View File

@@ -0,0 +1,170 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* A lapsed contract comes back only on the customer's say-so: they ask once,
* staff add days, and the contract lands back where it was before it expired.
* Those three rules are the feature.
*/
describe('ContractTransitionService — extension request / extend', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
contractKind: 'GENERAL',
status: 'EXPIRED',
freightType: 'CONTAINER',
contractValidUntil: new Date('2026-01-31T21:00:00Z'),
statusBeforeExpiry: 'CONTRACT_ACTIVE',
extensionRequestedAt: null,
...over,
}) as Contract;
let current: Contract;
let repo: { update: jest.Mock; createReviewNote: jest.Mock };
let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock };
let service: ContractTransitionService;
/** A staff user holding the extend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:extend' }],
};
beforeEach(() => {
current = contract();
repo = {
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() };
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('records the customer request and tells the contract desk', async () => {
await service.requestExtension('c-1', ' Two more shipments due ', 'user-1');
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Two more shipments due',
'EXTENSION_REQUESTED',
'user-1',
'CUSTOMER',
);
expect(repo.update).toHaveBeenCalledWith('c-1', {
extensionRequestedAt: expect.any(Date),
});
expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c-1' }),
'Two more shipments due',
);
});
it('refuses a request on a contract that has not expired', async () => {
current = contract({ status: 'CONTRACT_ACTIVE' });
await expect(
service.requestExtension('c-1', undefined, 'user-1'),
).rejects.toThrow(/CONTRACT_ACTIVE/);
expect(repo.update).not.toHaveBeenCalled();
});
it('allows one pending request at a time', async () => {
current = contract({ extensionRequestedAt: new Date() });
await expect(
service.requestExtension('c-1', undefined, 'user-1'),
).rejects.toThrow(/already awaiting/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses to extend before the customer has asked', async () => {
await expect(
service.extend('c-1', 30, undefined, 'staff-1', staff as never),
).rejects.toThrow(/not requested/);
expect(repo.update).not.toHaveBeenCalled();
});
it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => {
current = contract({
extensionRequestedAt: new Date(),
statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS',
});
const before = Date.now();
await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never);
const patch = repo.update.mock.calls[0][1] as {
status: string;
statusBeforeExpiry: null;
extensionRequestedAt: null;
contractValidUntil: Date;
};
expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS');
expect(patch.statusBeforeExpiry).toBeNull();
expect(patch.extensionRequestedAt).toBeNull();
// The old end (Jan 2026) is in the past, so the ten days count from now.
const tenDays = 10 * 86_400_000;
expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000);
expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000);
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/),
'EXTENDED',
'staff-1',
'STAFF',
);
expect(notifier.extended).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c-1' }),
10,
patch.contractValidUntil,
'Approved by desk',
);
});
it('extends from the current end date when it is still in the future', async () => {
const future = new Date(Date.now() + 5 * 86_400_000);
current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future });
await service.extend('c-1', 7, undefined, 'staff-1', staff as never);
const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date };
const expected = new Date(future);
expected.setDate(expected.getDate() + 7);
expect(patch.contractValidUntil.getTime()).toBe(expected.getTime());
});
it('falls back to the resting status for rows expired before it was tracked', async () => {
current = contract({
extensionRequestedAt: new Date(),
statusBeforeExpiry: null,
contractKind: 'ONE_TIME',
});
await service.extend('c-1', 1, undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith(
'c-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
});
it('refuses to extend without the extend permission', async () => {
current = contract({ extensionRequestedAt: new Date() });
await expect(
service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never),
).rejects.toThrow();
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -188,6 +188,26 @@ export class ContractNotifierService {
this.inApp(c, 'Contract cancelled', msg);
}
/** Staff extended the validity of a lapsed contract — it is live again. */
extended(c: Contract, days: number, validUntil: Date, note?: string | null): void {
const msg =
`Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` +
`and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` +
`You can book shipments under it again.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'EXTENDED');
this.inApp(c, 'Contract extended', msg);
}
/** Customer asked for their expired contract to be extended — staff-side record. */
extensionRequestedToStaff(c: Contract, note: string | null): void {
this.inAppStaff(
c,
'Contract extension requested',
`The customer asked to extend expired contract ${this.ref(c)}.` +
`${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`,
);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(

View File

@@ -430,6 +430,8 @@ export class ContractTransitionService {
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
// An empty-equipment contract resolves to the carriage-only paper.
contract.cargoCondition,
);
if (!active) return null;
return {
@@ -1500,6 +1502,105 @@ export class ContractTransitionService {
return updated;
}
/**
* Customer asks EDR to extend the validity of their EXPIRED contract. Only
* stamps the request and tells the contract desk — nothing on the contract
* moves until staff {@link extend} it. One pending request at a time.
*/
async requestExtension(
contractId: string,
note: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['EXPIRED']);
if (contract.extensionRequestedAt) {
throw new ConflictException(
'An extension request for this contract is already awaiting EDR.',
);
}
const reason = note?.trim() || null;
await this.contractsRepository.createReviewNote(
contractId,
reason ?? 'Customer requested a validity extension.',
'EXTENSION_REQUESTED',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
extensionRequestedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.extensionRequestedToStaff(updated, reason);
return updated;
}
/**
* Staff add validity days to an EXPIRED contract the customer asked to
* extend, and the contract returns to the status it held before it lapsed
* (stashed in statusBeforeExpiry by both expiry paths). Days count from
* today once the contract has lapsed — adding to a date already in the past
* could leave it expired — and from the current end date otherwise.
*
* Gated on the customer's request: the portal button is the only way to set
* extensionRequestedAt, so staff cannot silently revive a contract nobody
* asked about.
*/
async extend(
contractId: string,
days: number,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.extend);
assertContractStatus(contract, ['EXPIRED']);
if (!contract.extensionRequestedAt) {
throw new ConflictException(
'The customer has not requested an extension for this contract. A contract is only extended on customer request.',
);
}
if (!Number.isInteger(days) || days < 1) {
throw new BadRequestException('An extension must add at least one day.');
}
const now = new Date();
const currentEnd = contract.contractValidUntil
? new Date(contract.contractValidUntil)
: null;
const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now;
const validUntil = new Date(base);
validUntil.setDate(validUntil.getDate() + days);
// Rows that lapsed before statusBeforeExpiry existed have nothing to
// restore — fall back to the kind's post-signature resting status, the
// same default resume() uses.
const restored =
contract.statusBeforeExpiry ??
(contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED');
const trimmed = note?.trim() || null;
await this.contractsRepository.createReviewNote(
contractId,
`Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` +
(trimmed ? ` ${trimmed}` : ''),
'EXTENDED',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeExpiry: null,
extensionRequestedAt: null,
contractValidUntil: validUntil,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.extended(updated, days, validUntil, trimmed);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -78,6 +78,10 @@ import {
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import {
ExtendContractDto,
RequestContractExtensionDto,
} from './dto/extend-contract.dto';
import {
CompleteConsolidatedPairDto,
CreateBookingUnderContractDto,
@@ -528,6 +532,52 @@ export class ContractsController {
);
}
@Post(':id/extension-request')
@PortalCustomer()
@ApiOperation({
summary: 'Customer asks EDR to extend the validity of their expired contract',
})
async requestExtension(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestContractExtensionDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as cancel/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.requestExtension(
id,
dto.note,
resolveAuthUserId(user),
);
}
@Post(':id/extend')
@BookingStaff(FREIGHT_PERMS.contracts.extend)
@ApiOperation({
summary:
'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status',
})
extend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ExtendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.extend(
id,
dto.days,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@PortalCustomer()
@ApiOperation({

View File

@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
// SET reads the pre-update row, so status_before_expiry gets the status
// being replaced — the value ContractTransitionService.extend restores.
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
.where('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
@@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
.where('id = :id', { id })
.andWhere('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })

View File

@@ -433,6 +433,7 @@ export class ContractsService {
renewalOfId: dto.renewalOfId ?? null,
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN',
serviceTypeId: dto.serviceTypeId,
// A contract is always QUOTED in USD — the billing currency is chosen per
// booking (or on the shipment request when GL books for the customer), so
@@ -952,6 +953,20 @@ export class ContractsService {
}
}
// Why the customer wants more time — shown on the staff detail page while
// the extension request is pending.
if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'EXTENSION_REQUESTED',
);
contract.latestExtensionRequestNote = note?.body ?? null;
} catch {
contract.latestExtensionRequestNote = 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 =

View File

@@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
// Canonical UPPERCASE — everything downstream (booking gating, pricing
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
@@ -154,6 +155,15 @@ export class CreateContractDto {
@IsIn([...FREIGHT_TYPES])
freightType!: string;
/**
* LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is
* container freight only.
*/
@ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' })
@IsOptional()
@IsIn([...CARGO_CONDITIONS])
cargoCondition?: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
export class RequestContractExtensionDto {
@ApiPropertyOptional({ description: 'Why the customer needs the contract extended' })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
/** Staff extend an EXPIRED contract that the customer asked to extend. */
export class ExtendContractDto {
@ApiProperty({
description:
'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.',
minimum: 1,
maximum: 3650,
})
@IsInt()
@Min(1)
@Max(3650)
days!: number;
@ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
/** Customer asked for an EXPIRED contract's validity to be extended. */
'EXTENSION_REQUESTED',
/** Staff extended the validity; body records the days added and the new end. */
'EXTENDED',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -150,6 +150,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
freightType!: string;
/**
* LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract
* commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER
* template — a straight carriage agreement with no cargo or customs articles.
*/
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
cargoCondition!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@@ -231,6 +239,23 @@ export class Contract extends BaseEntity {
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
/**
* Status the contract held when it lapsed to EXPIRED (stamped by both the
* nightly sweep and the lazy flip on read), restored when staff extend the
* validity. Null on rows that expired before the column existed — extension
* then falls back to the kind's post-signature resting status.
*/
@Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true })
statusBeforeExpiry?: string | null;
/**
* When the customer asked for the validity of this EXPIRED contract to be
* extended. Set by the portal request, cleared when staff extend. Staff
* cannot extend a contract the customer has not asked about.
*/
@Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true })
extensionRequestedAt?: Date | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -365,6 +390,13 @@ export class Contract extends BaseEntity {
*/
latestSuspensionNote?: string | null;
/**
* Body of the most recent EXTENSION_REQUESTED review note, attached by
* ContractsService.findById while an extension request is pending so staff
* see why the customer wants the contract extended. Not a column.
*/
latestExtensionRequestNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while

View File

@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
);
});
});
describe('deriveRateType — empty container freight', () => {
it('splits empty freight from laden freight by direction', () => {
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
'EMPTY_CONTAINER_IMPORT',
);
expect(
deriveRateType({
appliesTo: 'EMPTY_CONTAINER',
trigger: 'ALWAYS',
tradeDirection: 'EXPORT',
}),
).toBe('EMPTY_CONTAINER_EXPORT');
});
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
// lane and container type. The distinct rateType is what keeps both fileable.
it('never resolves to the laden container rate type', () => {
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
expect(
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
}
});
});

View File

@@ -58,6 +58,8 @@ export function deriveRateType(input: {
switch (appliesTo) {
case 'CONTAINER':
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
case 'EMPTY_CONTAINER':
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
case 'BULK':
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
case 'INTERCITY':

View File

@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
expect(isBulkQuantityUnit("FLAT")).toBe(false);
});
});
/**
* Empty equipment carries no cargo, so no weighed unit applies — only the box
* and the wagon it rides on.
*/
describe("allowedRateUnits — empty container freight", () => {
it("offers per-container and per-wagon only", () => {
expect(
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
});
it("never offers a weighed unit, even for a per-item commodity scope", () => {
expect(
allowedRateUnits({
appliesTo: "EMPTY_CONTAINER",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).not.toContain("PER_ITEM");
});
});

View File

@@ -98,6 +98,10 @@ function unitsForShape(input: {
switch (appliesTo) {
case 'CONTAINER':
return ['PER_CONTAINER', 'PER_WAGON'];
case 'EMPTY_CONTAINER':
// Empty equipment carries no cargo to weigh, so the only bases that mean
// anything are the box itself and the wagon it rides on.
return ['PER_CONTAINER', 'PER_WAGON'];
case 'BULK':
return ['PER_TON', 'PER_WAGON'];
case 'INTERCITY':

View File

@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
export const RATE_TYPES = [
'CONTAINER_IMPORT',
'CONTAINER_EXPORT',
// Empty equipment moved as freight in its own right — no cargo, priced per
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
// would collide with the laden 40ft rate for the same lane.
'EMPTY_CONTAINER_IMPORT',
'EMPTY_CONTAINER_EXPORT',
'BULK_IMPORT',
'BULK_EXPORT',
'INTERCITY_BULK',
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
* lookup and snapshots).
*
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
* - EMPTY_CONTAINER : base rail freight for empty equipment
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
* - OTHER : trigger-based surcharges (hazard, reefer …)
*/
export const RATE_APPLIES_TO = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
'FIRST_MILE',
'LAST_MILE',

View File

@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
@@ -381,6 +386,30 @@ export class RatesService {
return;
}
if (appliesTo === 'EMPTY_CONTAINER') {
// Northbound repositioning only. Southbound empties are already sold by
// the WITH_RETURN surcharge and empty_return_requests; a second path to
// the same movement would let the business double-sell it.
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'An empty container rate is import-only for now.',
);
}
// Size is the entire scope of an empty rate — there is no cargo to narrow
// by, so the box type must be named and a commodity must not be.
if (!containerTypeId) {
throw new BadRequestException(
'An empty container rate must name the container type it covers.',
);
}
if (cargoTypeId) {
throw new BadRequestException(
'An empty container rate cannot be scoped to a bulk cargo type.',
);
}
return;
}
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,

View File

@@ -0,0 +1,346 @@
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
legAllowsNationality,
overtimeHours,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
/**
* A slice of the real corridor, using the production display_order values:
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
*/
const YARD: Record<string, CorridorYard> = {
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
};
const CORRIDOR: CorridorContext = {
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
originOrder: YARD.GMP.displayOrder,
destinationOrder: YARD.NAGAD.displayOrder,
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
};
const NO_DEMAND: CrewDemandInput = {
hasBadOrderWagon: false,
badOrderWagonLabels: [],
hasReeferCargo: false,
reeferSources: [],
hasHazmatCargo: false,
hazmatSources: [],
hasBreakBulkCargo: false,
breakBulkSources: [],
hasLivestockCargo: false,
livestockSources: [],
};
let seq = 0;
const driver = (
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
from: CorridorYard,
to: CorridorYard,
dutyRole: CrewDutyRole,
): AssignmentFacts => ({
crewMemberId: `driver-${++seq}`,
role: TrainCrewRole.TRAIN_DRIVER,
dutyRole,
fromYardId: from.id,
toYardId: to.id,
nationality,
memberName: `Driver ${seq}`,
});
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
Array.from({ length: count }, () => ({
crewMemberId: `member-${++seq}`,
role,
nationality: 'ETHIOPIAN',
memberName: `Member ${seq}`,
}));
const codes = (result: { issues: Array<{ code: string }> }) =>
result.issues.map((i) => i.code);
describe('crew composition rules (ITLMS Rolling Stock)', () => {
const validate = (
assignments: AssignmentFacts[],
demand: CrewDemandInput = NO_DEMAND,
) => validateCrewComposition(assignments, demand, CORRIDOR);
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
const soloPrimary = () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
describe('free-form crew sizing', () => {
it('accepts a single driver working the whole corridor', () => {
const result = validate([soloPrimary()]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
const drivers = [
soloPrimary(),
...Array.from({ length: count - 1 }, () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
),
];
expect(validate(drivers).complete).toBe(true);
});
it('accepts any number of federal police, including none', () => {
for (const count of [0, 1, 4, 9]) {
const result = validate([
soloPrimary(),
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
]);
expect(result.complete).toBe(true);
}
});
it('requires at least one driver', () => {
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
expect(codes(result)).toContain('DRIVER_COUNT');
});
});
describe('yard-to-yard legs', () => {
it('lets staff hand over at any intermediate yard', () => {
// Three legs the old fixed segments could not express: GMPFeto,
// FetoMeiso, MeisoNagad.
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it('rejects a leg with the same yard at both ends', () => {
const result = validate([
driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DRIVER_LEG_EMPTY');
});
it('rejects a yard outside the schedule route', () => {
const outside: CorridorYard = {
id: 'y-sebeta',
label: 'Sebeta',
country: 'Ethiopia',
displayOrder: 1, // before the GMP origin
};
const corridor: CorridorContext = {
...CORRIDOR,
yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]),
};
const result = validateCrewComposition(
[driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)],
NO_DEMAND,
corridor,
);
expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE');
});
it('requires a from-yard, to-yard and duty role on every driver', () => {
const result = validate([
{
crewMemberId: 'd1',
role: TrainCrewRole.TRAIN_DRIVER,
nationality: 'ETHIOPIAN',
memberName: 'Unslotted Driver',
},
]);
expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE');
});
});
describe('§1.1 territorial boundary', () => {
const dd = YARD.DIRE_DAWA.displayOrder;
it('lets a Djibouti driver work at or beyond Dire Dawa', () => {
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true);
});
it('bars a Djibouti driver from any leg west of Dire Dawa', () => {
expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false);
expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false);
});
it('leaves Ethiopian drivers unrestricted', () => {
expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
});
it('flags a Djibouti driver placed on a western leg', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('TERRITORIAL_BOUNDARY');
});
it('accepts the documented split: Ethiopians west, Djiboutians east', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(result.issues).toEqual([]);
expect(result.runType).toBe('LONG_RUN');
});
});
describe('one Primary per leg', () => {
it('rejects two Primaries on the same leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DUPLICATE_PRIMARY');
});
it('allows a Primary on each of two different legs', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.complete).toBe(true);
});
it('allows many Assistants alongside one Primary', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF),
]);
expect(result.complete).toBe(true);
});
it('requires a Primary on every covered leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(codes(result)).toContain('PRIMARY_MISSING');
});
});
describe('§1.1 run type', () => {
it('is a long run when the legs span the whole route', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('LONG_RUN');
});
it('is a short run when the legs cover only part of the route', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('SHORT_RUN');
});
});
describe('§1.2 technical maintenance crew', () => {
it('requires no technician when no bad-order wagon is attached', () => {
expect(technicianRequirement(NO_DEMAND).min).toBe(0);
});
it('forces one technician when a bad-order wagon is attached', () => {
const demand = {
...NO_DEMAND,
hasBadOrderWagon: true,
badOrderWagonLabels: ['WG-1042'],
};
expect(technicianRequirement(demand).min).toBe(1);
const result = validate([soloPrimary()], demand);
expect(codes(result)).toContain('TECHNICIAN_REQUIRED');
// The wagon that forced it is named, so the demand is explicable.
expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message)
.toContain('WG-1042');
});
});
describe('§1.2 specialized cargo crew', () => {
it('asks for nothing when no specialized cargo is aboard', () => {
expect(specializedRequirements(NO_DEMAND)).toEqual([]);
});
it('requires a reefer technician only when reefer cargo is aboard', () => {
const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] };
const rules = specializedRequirements(demand);
expect(rules).toHaveLength(1);
expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN);
expect(rules[0].min).toBe(1);
});
it('blocks a hazmat run with no escort assigned', () => {
const result = validate([soloPrimary()], {
...NO_DEMAND,
hasHazmatCargo: true,
hazmatSources: ['BK-2024-0891'],
});
expect(codes(result)).toContain('SPECIALIZED_REQUIRED');
expect(result.complete).toBe(false);
});
it('passes once the escort is assigned, at any count', () => {
for (const escorts of [1, 2, 5]) {
const result = validate(
[soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)],
{ ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] },
);
expect(result.complete).toBe(true);
}
});
});
describe('duplicate seats', () => {
it('flags a member assigned twice on one run', () => {
const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0];
const result = validate([soloPrimary(), twice, twice]);
expect(codes(result)).toContain('DUPLICATE_MEMBER');
});
});
describe('§3.2 overtime hours', () => {
it('reproduces the documented worked example', () => {
// PDF: 500h worked against a 240h standard => 260h variance,
// split 156h at the 1.5x tier and 104h at the 1.75x tier.
expect(overtimeHours(500)).toEqual({
variance: 260,
tier1Hours: 156,
tier2Hours: 104,
});
});
it('reports no overtime below the monthly standard', () => {
expect(overtimeHours(200)).toEqual({
variance: 0,
tier1Hours: 0,
tier2Hours: 0,
});
});
it('splits the variance 60/40 as a flat convention', () => {
const { tier1Hours, tier2Hours, variance } = overtimeHours(340);
expect(variance).toBe(100);
expect(tier1Hours).toBe(60);
expect(tier2Hours).toBe(40);
});
});
});

View File

@@ -0,0 +1,437 @@
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
CrewDutyRole,
CrewSegment,
} from './entities/train-crew-assignment.entity';
/**
* ITLMS Rolling Stock §1.2 / §2 composition rules.
*
* One module, used by BOTH the assignment API and the dispatch guard, so the
* wizard and the departure gate can never disagree about whether a crew is
* complete. Pure functions over plain data — no repository access — so the
* caller decides what to load and this stays unit-testable.
*/
/**
* Security-detail size (§1.2 names 4 federal police).
*
* Operations asked for free-form crewing, so the document's numbers are treated
* as the usual shape rather than a hard limit — any count is accepted and the
* typical value is surfaced as a hint in the UI.
*/
export const FEDERAL_POLICE_TYPICAL = 4;
/** Government monthly working-hour baseline (§3.1). */
export const MONTHLY_STANDARD_HOURS = 240;
/**
* §3.2 tier split. The document fixes the day/night division as a flat 60/40 of
* the variance regardless of when the hours fell, and that is implemented as
* written rather than derived from real clock hours.
*/
export const OT_TIER_1_SHARE = 0.6;
export const OT_TIER_2_SHARE = 0.4;
export const OT_TIER_1_FACTOR = 1.5;
export const OT_TIER_2_FACTOR = 1.75;
/**
* Driving-crew size (§1.2 "3 or 4 Drivers").
*
* Operations asked for a free-form crew rather than the two fixed pairing cases
* of §2, so the document's 3-or-4 is treated as the usual shape, not a limit:
* any count within these bounds is accepted and each driver carries their own
* segment and duty role. MIN stays at 1 so a partially built crew still saves.
*/
export const DRIVER_COUNT_MIN = 1;
/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */
export const DRIVER_COUNT_TYPICAL = [3, 4];
/**
* A corridor yard as the rules see it.
*
* `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD
* 22), which is what makes "is this leg inside the schedule's span" and "does
* this leg cross into Djibouti" answerable without hard-coding station names.
*/
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
/** Dire Dawa is the handover point §1.1 draws the territorial line at. */
export const DIRE_DAWA_CODE = 'DIRE_DAWA';
/**
* The corridor a schedule runs on, as the rules need to see it: every yard by
* id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by
* the caller so these functions stay pure and unit-testable.
*/
export interface CorridorContext {
yards?: Map<string, CorridorYard>;
originOrder?: number;
destinationOrder?: number;
direDawaOrder?: number;
}
/**
* §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa Nagad
* corridor segment exclusively.
*
* Expressed against yards rather than a fixed segment name: a leg is open to a
* Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point
* east of it works without naming the pair in code. The restriction is
* asymmetric on purpose — the document confines Djiboutian drivers but never
* bars Ethiopians from that stretch.
*/
export const legAllowsNationality = (
from: CorridorYard | undefined,
to: CorridorYard | undefined,
nationality: string,
direDawaOrder: number,
): boolean => {
if (nationality !== 'DJIBOUTIAN') return true;
if (!from || !to) return true; // Incomplete leg — a separate rule reports it.
// Both ends must sit at or beyond Dire Dawa, whichever way the train runs.
return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder;
};
/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */
export interface SpecializedRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint for the UI; nothing enforces it. */
typical: number;
/** Why this is required — surfaced verbatim so the demand is explicable. */
reason: string;
}
/** What the consist and its cargo demand, as detected from the schedule. */
export interface CrewDemandInput {
/** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
const listSources = (sources: string[]): string =>
sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : '';
/**
* Turn detected cargo/consist facts into the crew the run must carry.
* Only triggered rows appear, so staff are never asked about cargo not aboard.
*/
export const specializedRequirements = (
demand: CrewDemandInput,
): SpecializedRequirement[] => {
const required: SpecializedRequirement[] = [];
if (demand.hasReeferCargo) {
required.push({
role: TrainCrewRole.REEFER_TECHNICIAN,
min: 1,
typical: 2,
reason: `Reefer cargo on board${listSources(demand.reeferSources)}`,
});
}
if (demand.hasHazmatCargo) {
required.push({
role: TrainCrewRole.HAZMAT_ESCORT,
min: 1,
typical: 2,
reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`,
});
}
if (demand.hasBreakBulkCargo) {
required.push({
role: TrainCrewRole.LASHING_INSPECTOR,
min: 1,
typical: 2,
reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`,
});
}
if (demand.hasLivestockCargo) {
required.push({
role: TrainCrewRole.LIVESTOCK_HANDLER,
min: 1,
typical: 3,
reason: `Livestock shipment on board${listSources(demand.livestockSources)}`,
});
}
return required;
};
/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */
export const technicianRequirement = (
demand: CrewDemandInput,
): SpecializedRequirement => ({
role: TrainCrewRole.TECHNICIAN,
min: demand.hasBadOrderWagon ? 1 : 0,
typical: 3,
reason: demand.hasBadOrderWagon
? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}`
: 'Optional technical maintenance crew',
});
/** One assignment, reduced to what the rules actually read. */
export interface AssignmentFacts {
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
/** The leg this driver works, as two corridor yards. */
fromYardId?: string | null;
toYardId?: string | null;
nationality: string;
memberName: string;
}
export interface CrewValidationIssue {
code: string;
message: string;
}
export interface CrewValidationResult {
/** True when every mandatory rule passes — the dispatch gate reads this. */
complete: boolean;
issues: CrewValidationIssue[];
/** Derived, never entered: one segment covered = short run, both = long run (§1.1). */
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
/**
* Validate a schedule's crew against §1.1 and §1.2.
*
* Returns issues rather than throwing: the wizard renders them as a live
* checklist while a partial crew is still being built, and only the dispatch
* guard treats a non-empty list as fatal.
*/
export const validateCrewComposition = (
assignments: AssignmentFacts[],
demand: CrewDemandInput,
corridor: CorridorContext = {},
): CrewValidationResult => {
const yards = corridor.yards ?? new Map<string, CorridorYard>();
const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY;
const issues: CrewValidationIssue[] = [];
const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER);
if (drivers.length < DRIVER_COUNT_MIN) {
issues.push({
code: 'DRIVER_COUNT',
message: 'At least one driver must be assigned',
});
}
// Every driver needs a leg and a duty role — without them the run has no
// record of who drove which part of the corridor.
for (const driver of drivers) {
if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) {
issues.push({
code: 'DRIVER_SLOT_INCOMPLETE',
message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`,
});
continue;
}
if (driver.fromYardId === driver.toYardId) {
issues.push({
code: 'DRIVER_LEG_EMPTY',
message: `${driver.memberName} has the same yard at both ends of their leg`,
});
}
// A leg outside the schedule's own span would put a driver on track this
// train never runs.
if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) {
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
const from = yards.get(driver.fromYardId);
const to = yards.get(driver.toYardId);
for (const yard of [from, to]) {
if (yard && (yard.displayOrder < low || yard.displayOrder > high)) {
issues.push({
code: 'LEG_OUTSIDE_ROUTE',
message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`,
});
}
}
}
}
// A leg cannot have two Primaries — someone must be in charge of each stretch
// and only one person can be. Assistants and relief drivers are unconstrained.
const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`;
const legLabel = (d: AssignmentFacts) => {
const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined;
const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined;
return from && to ? `${from} ${to}` : 'this leg';
};
const primariesByLeg = new Map<string, { names: string[]; label: string }>();
for (const driver of drivers) {
if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) {
const key = legKey(driver);
const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) };
entry.names.push(driver.memberName);
primariesByLeg.set(key, entry);
}
}
for (const [, entry] of primariesByLeg) {
if (entry.names.length > 1) {
issues.push({
code: 'DUPLICATE_PRIMARY',
message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`,
});
}
}
// Each covered leg needs a Primary — an Assistant alone cannot run it.
const coveredLegs = new Map<string, string>();
for (const driver of drivers) {
if (driver.fromYardId && driver.toYardId) {
coveredLegs.set(legKey(driver), legLabel(driver));
}
}
for (const [key, label] of coveredLegs) {
if (!primariesByLeg.has(key)) {
issues.push({
code: 'PRIMARY_MISSING',
message: `${label} has no Primary Driver assigned`,
});
}
}
// §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa.
for (const driver of drivers) {
const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined;
const to = driver.toYardId ? yards.get(driver.toYardId) : undefined;
if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) {
issues.push({
code: 'TERRITORIAL_BOUNDARY',
message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
});
}
}
// §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are
// no longer enforced: operations crew each run to its own need, so any number
// of any role is accepted. What still holds is what makes a run coherent —
// a driver with a segment and duty role, one Primary per segment, and the
// specialized crew the cargo actually demands.
// §1.2 technical maintenance crew: a bad-order wagon still forces at least
// one technician — that rule is about safety, not crew sizing, so it stays.
const technicianRule = technicianRequirement(demand);
const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length;
if (technicians < technicianRule.min) {
issues.push({
code: 'TECHNICIAN_REQUIRED',
message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`,
});
}
// §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort
// rides along) but the upper bound is gone — how many is operations' call.
for (const rule of specializedRequirements(demand)) {
const count = assignments.filter((a) => a.role === rule.role).length;
if (count < rule.min) {
issues.push({
code: 'SPECIALIZED_REQUIRED',
message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`,
});
}
}
// Nobody may hold two seats on the same run.
const seen = new Set<string>();
for (const a of assignments) {
if (seen.has(a.crewMemberId)) {
issues.push({
code: 'DUPLICATE_MEMBER',
message: `${a.memberName} is assigned more than once on this run`,
});
}
seen.add(a.crewMemberId);
}
return {
complete: issues.length === 0,
issues,
runType: deriveRunType(drivers, corridor, yards),
};
};
/**
* §1.1 run type. A crew whose legs together span the schedule's whole route is
* a long run; anything shorter is a short run.
*/
const deriveRunType = (
drivers: AssignmentFacts[],
corridor: CorridorContext,
yards: Map<string, CorridorYard>,
): 'SHORT_RUN' | 'LONG_RUN' | null => {
const orders = drivers
.flatMap((d) => [d.fromYardId, d.toYardId])
.map((id) => (id ? yards.get(id)?.displayOrder : undefined))
.filter((o): o is number => o !== undefined);
if (!orders.length) return null;
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return 'SHORT_RUN';
}
const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder);
const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder);
const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh;
return covered ? 'LONG_RUN' : 'SHORT_RUN';
};
export const labelSegment = (segment: CrewSegment): string =>
({
[CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP Dire Dawa',
[CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa Nagad',
[CrewSegment.FULL_CORRIDOR]: 'Full corridor',
})[segment];
export const labelDutyRole = (dutyRole: CrewDutyRole): string =>
({
[CrewDutyRole.PRIMARY]: 'Primary Driver',
[CrewDutyRole.ASSISTANT]: 'Assistant Driver',
[CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver',
})[dutyRole];
export const labelRole = (role: TrainCrewRole): string =>
({
[TrainCrewRole.TRAIN_DRIVER]: 'train driver',
[TrainCrewRole.FEDERAL_POLICE]: 'federal police',
[TrainCrewRole.TECHNICIAN]: 'technician',
[TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician',
[TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort',
[TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector',
[TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler',
})[role];
/**
* §3.2 overtime hours for one driver's month.
*
* Hours only, by design: no salary is stored anywhere in the platform, so the
* output stops at the two tier totals and finance applies the rates.
*/
export const overtimeHours = (
workedHours: number,
standardHours: number = MONTHLY_STANDARD_HOURS,
): { variance: number; tier1Hours: number; tier2Hours: number } => {
const variance = Math.max(0, workedHours - standardHours);
return {
variance,
tier1Hours: variance * OT_TIER_1_SHARE,
tier2Hours: variance * OT_TIER_2_SHARE,
};
};

View File

@@ -0,0 +1,32 @@
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
export class CreateTrainCrewMemberDto {
@IsString()
@MinLength(1)
@MaxLength(100)
firstName!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
lastName!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
@IsEnum(TrainCrewNationality)
nationality!: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */
export const TRAIN_CREW_SORT_FIELDS = [
'firstName',
'lastName',
'role',
'nationality',
'status',
'createdAt',
'updatedAt',
] as const;
export class QueryTrainCrewMemberDto {
/** Matched against first and last name. */
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(TrainCrewRole)
role?: TrainCrewRole;
@IsOptional()
@IsEnum(TrainCrewNationality)
nationality?: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@IsOptional()
@IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[])
sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number];
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,45 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { CrewDutyRole } from '../entities/train-crew-assignment.entity';
import { TrainCrewRole } from '../entities/train-crew-member.entity';
export class CrewAssignmentRowDto {
@IsUUID()
crewMemberId!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
/** Required for drivers, rejected as incomplete without it. */
@IsOptional()
@IsEnum(CrewDutyRole)
dutyRole?: CrewDutyRole;
/** The leg this driver works — any two yards on the schedule's route. */
@IsOptional()
@IsUUID()
fromYardId?: string;
@IsOptional()
@IsUUID()
toYardId?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class SaveCrewAssignmentsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CrewAssignmentRowDto)
assignments!: CrewAssignmentRowDto[];
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}

View File

@@ -0,0 +1,114 @@
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity';
/**
* Legacy fixed corridor segments.
*
* Kept only so historic rows written before segments became yard-to-yard still
* read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick
* any two yards on the corridor, so a leg is no longer limited to the three
* spans the original design hard-coded.
*/
export enum CrewSegment {
INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA',
DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD',
FULL_CORRIDOR = 'FULL_CORRIDOR',
}
/** Driver duty role for one run (§2). Null for non-driving crew. */
export enum CrewDutyRole {
PRIMARY = 'PRIMARY',
ASSISTANT = 'ASSISTANT',
BENCH_RELIEF = 'BENCH_RELIEF',
}
export enum CrewAssignmentStatus {
PLANNED = 'PLANNED',
CONFIRMED = 'CONFIRMED',
COMPLETED = 'COMPLETED',
REMOVED = 'REMOVED',
}
/**
* One roster member assigned to one train schedule.
*
* `role` is snapshotted from the roster at assignment time: a member who later
* changes role must not silently rewrite the crew of a run that already
* departed. `dutyRole` and `segment` live here rather than on the roster
* because they are properties of THIS run — a driver who is Primary on one
* trip is Assistant on the next. Crew sizes are free-form: operations size each
* run to its own need rather than to a fixed pairing case.
*
* Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the
* platform reports OT hours only; no salary is stored anywhere, and the payroll
* conversion stays with finance.
*/
@Entity({ schema: 'freight', name: 'train_crew_assignments' })
@Index(['trainScheduleId'])
@Index(['crewMemberId'])
@Index(['status'])
export class TrainCrewAssignment extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'crew_member_id', type: 'uuid' })
crewMemberId!: string;
@ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'crew_member_id' })
crewMember?: TrainCrewMember;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true })
dutyRole?: CrewDutyRole | null;
/** Legacy fixed segment — null on every assignment written since yard legs. */
@Column({ name: 'segment', type: 'varchar', length: 24, nullable: true })
segment?: CrewSegment | null;
/**
* The leg this driver works, as two yards on the corridor.
*
* Free-form on purpose: operations pick any yard as a handover point, so a
* crew change at Meiso or Feto is expressible without a code change. The
* schedule's own origin and destination bound what staff may choose.
*/
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
fromYardId?: string | null;
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
toYardId?: string | null;
/**
* Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours
* as a typical duration, not a rule, so nothing here enforces a length — the
* stamps are recorded and reported.
*/
@Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true })
layoverStartAt?: Date | null;
@Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true })
layoverEndAt?: Date | null;
/** Worked span for this run — accumulated monthly for the §3 OT calculation. */
@Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true })
dutyStartAt?: Date | null;
@Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true })
dutyEndAt?: Date | null;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: CrewAssignmentStatus.PLANNED,
})
status!: CrewAssignmentStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,69 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* On-board role a crew member is rostered for. Mirrors the crew composition
* rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security
* detail, technical maintenance, and the four specialized cargo roles.
*/
export enum TrainCrewRole {
TRAIN_DRIVER = 'TRAIN_DRIVER',
FEDERAL_POLICE = 'FEDERAL_POLICE',
TECHNICIAN = 'TECHNICIAN',
REEFER_TECHNICIAN = 'REEFER_TECHNICIAN',
HAZMAT_ESCORT = 'HAZMAT_ESCORT',
LASHING_INSPECTOR = 'LASHING_INSPECTOR',
LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER',
}
/**
* Employing country. Drives the territorial boundary in §1.1 — Djibouti train
* drivers operate only on the Dire Dawa Nagad segment — and the crewing
* cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers).
*/
export enum TrainCrewNationality {
ETHIOPIAN = 'ETHIOPIAN',
DJIBOUTIAN = 'DJIBOUTIAN',
}
export enum TrainCrewStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
ON_LEAVE = 'ON_LEAVE',
}
/**
* Roster of people assignable to a train. Distinct from `freight.drivers`,
* which is the road/last-mile truck driver register (licences, vehicle types,
* trip counts) — a train driver shares none of those fields.
*/
@Entity({ schema: 'freight', name: 'train_crew_members' })
@Index(['role'])
@Index(['nationality'])
@Index(['status'])
@Index(['isActive'])
export class TrainCrewMember extends BaseEntity {
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'nationality', type: 'varchar', length: 16 })
nationality!: TrainCrewNationality;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: TrainCrewStatus.ACTIVE,
})
status!: TrainCrewStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
@ApiTags('train-crew-assignments')
@ApiBearerAuth()
@Controller('train-schedules/:scheduleId/crew')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([FREIGHT_PERMS.trainCrew.view, FREIGHT_PERMS.trainCrew.assign])
export class TrainCrewAssignmentController {
constructor(private readonly service: TrainCrewAssignmentService) {}
@Get()
@ApiOperation({
summary: "A schedule's crew, the cargo-driven requirements, and rule validation",
})
getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.getScheduleCrew(scheduleId);
}
@Get('eligible-drivers')
@ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' })
eligibleDrivers(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Query('fromYardId') fromYardId?: string,
@Query('toYardId') toYardId?: string,
) {
return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId);
}
@Get('corridor-yards')
@ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" })
corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.corridorYards(scheduleId);
}
@Put()
@BookingStaff(FREIGHT_PERMS.trainCrew.assign)
@ApiOperation({
summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)",
})
save(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: SaveCrewAssignmentsDto,
) {
return this.service.saveAssignments(scheduleId, dto);
}
}

View File

@@ -0,0 +1,389 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import {
CrewAssignmentStatus,
TrainCrewAssignment,
} from './entities/train-crew-assignment.entity';
import {
TrainCrewMember,
TrainCrewRole,
TrainCrewStatus,
} from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
CrewValidationResult,
DIRE_DAWA_CODE,
labelRole,
legAllowsNationality,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
/** Wagon statuses that mean "defective / bad order" for §1.2. */
const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE'];
/**
* Cargo-type name fragments that mark a livestock shipment. Matched on the
* cargo type's name because no boolean flag for livestock exists yet — unlike
* reefer and hazardous, which bookings carry explicitly.
*/
const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry'];
@Injectable()
export class TrainCrewAssignmentService {
constructor(
@InjectRepository(TrainCrewAssignment)
private readonly assignmentRepo: Repository<TrainCrewAssignment>,
@InjectRepository(TrainCrewMember)
private readonly memberRepo: Repository<TrainCrewMember>,
private readonly dataSource: DataSource,
) {}
/** Every assignment on a schedule, with the roster member joined. */
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
return this.assignmentRepo.find({
where: {
trainScheduleId: scheduleId,
status: In([
CrewAssignmentStatus.PLANNED,
CrewAssignmentStatus.CONFIRMED,
CrewAssignmentStatus.COMPLETED,
]),
},
relations: { crewMember: true },
order: { createdAt: 'ASC' },
});
}
/**
* What this schedule's consist and cargo demand (§1.2).
*
* Read straight from the train set and its allocations rather than asked of
* the user: the wagons and bookings already say whether a bad-order wagon is
* attached and whether reefer, hazardous, break-bulk or livestock cargo is
* aboard, so the requirement is derived and every row can name its trigger.
*/
async detectDemand(scheduleId: string): Promise<CrewDemandInput> {
const badOrder: Array<{ label: string }> = await this.dataSource.query(
`
SELECT COALESCE(w.wagon_number, tsw.id::text) AS label
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE ts.id = $1
AND w.status = ANY($2)
`,
[scheduleId, BAD_ORDER_WAGON_STATUSES],
);
const cargo: Array<{
reference: string | null;
is_reefer: boolean;
is_hazardous: boolean;
load_type: string | null;
cargo_type_name: string | null;
}> = await this.dataSource.query(
`
SELECT DISTINCT
b.reference,
b.is_reefer,
b.is_hazardous,
wba.load_type,
ct.cargo_type_name
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id
JOIN freight.bookings b ON b.id = wba.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE ts.id = $1
`,
[scheduleId],
);
const label = (row: { reference: string | null }) => row.reference ?? 'a booking';
const isLivestock = (name: string | null) =>
Boolean(name) &&
LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint));
const reefer = cargo.filter((c) => c.is_reefer);
const hazmat = cargo.filter((c) => c.is_hazardous);
// Break-bulk rides as a bulk allocation rather than a container.
const breakBulk = cargo.filter((c) => c.load_type === 'BULK');
const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name));
return {
hasBadOrderWagon: badOrder.length > 0,
badOrderWagonLabels: badOrder.map((w) => w.label),
hasReeferCargo: reefer.length > 0,
reeferSources: reefer.map(label),
hasHazmatCargo: hazmat.length > 0,
hazmatSources: hazmat.map(label),
hasBreakBulkCargo: breakBulk.length > 0,
breakBulkSources: breakBulk.map(label),
hasLivestockCargo: livestock.length > 0,
livestockSources: livestock.map(label),
};
}
/**
* The corridor this schedule runs on: every active yard by id, plus where the
* schedule starts, ends, and where Dire Dawa sits. `display_order` is the
* yard's place along the line, which is what lets the rules answer "is this
* leg inside the route" and "does it cross the territorial boundary" without
* hard-coding station names.
*/
async loadCorridor(scheduleId: string): Promise<CorridorContext> {
const rows: Array<{
id: string;
code: string;
label: string;
country: string;
display_order: number;
}> = await this.dataSource.query(
`SELECT id, code, label, country, display_order
FROM freight.yards
WHERE is_active = true
ORDER BY display_order ASC`,
);
const yards = new Map<string, CorridorYard>(
rows.map((r) => [
r.id,
{
id: r.id,
label: r.label,
country: r.country,
displayOrder: Number(r.display_order),
},
]),
);
const [schedule]: Array<{
origin_station_id: string | null;
destination_station_id: string | null;
}> = await this.dataSource.query(
`SELECT origin_station_id, destination_station_id
FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
const orderOf = (id: string | null | undefined) =>
id ? yards.get(id)?.displayOrder : undefined;
return {
yards,
originOrder: orderOf(schedule?.origin_station_id),
destinationOrder: orderOf(schedule?.destination_station_id),
direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE)
? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order)
: undefined,
};
}
/** Yards a driver leg may use — every yard between origin and destination. */
async corridorYards(scheduleId: string): Promise<CorridorYard[]> {
const corridor = await this.loadCorridor(scheduleId);
const all = [...(corridor.yards?.values() ?? [])].sort(
(a, b) => a.displayOrder - b.displayOrder,
);
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return all;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high);
}
/**
* Full picture for one schedule: who is assigned, what the cargo demands, and
* which composition rules currently fail. The wizard renders this directly.
*/
async getScheduleCrew(scheduleId: string) {
const [assignments, demand, corridor] = await Promise.all([
this.listForSchedule(scheduleId),
this.detectDemand(scheduleId),
this.loadCorridor(scheduleId),
]);
const validation = validateCrewComposition(
assignments.map(toFacts),
demand,
corridor,
);
return {
scheduleId,
assignments,
corridorYards: [...(corridor.yards?.values() ?? [])]
.filter((y) => {
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return true;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return y.displayOrder >= low && y.displayOrder <= high;
})
.sort((a, b) => a.displayOrder - b.displayOrder),
demand,
requirements: {
technician: technicianRequirement(demand),
specialized: specializedRequirements(demand),
},
validation,
};
}
/**
* Replace a schedule's crew in one transaction.
*
* A whole-set replace rather than per-row edits: the wizard submits the
* finished crew, and composition rules are only meaningful over the complete
* set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster
* over days, and §1.2 places the hard gate at departure, not at save time.
* Only structural errors (unknown member, wrong role, territorial breach)
* reject here; the rest surface as issues and block dispatch.
*/
async saveAssignments(
scheduleId: string,
dto: SaveCrewAssignmentsDto,
): Promise<CrewValidationResult> {
const rows = dto.assignments ?? [];
const memberIds = rows.map((r) => r.crewMemberId);
const corridor = await this.loadCorridor(scheduleId);
const members = memberIds.length
? await this.memberRepo.find({ where: { id: In(memberIds) } })
: [];
const byId = new Map(members.map((m) => [m.id, m]));
for (const row of rows) {
const member = byId.get(row.crewMemberId);
if (!member) {
throw new NotFoundException(`Crew member ${row.crewMemberId} not found`);
}
if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`,
);
}
if (row.role !== member.role) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`,
);
}
if (member.role === TrainCrewRole.TRAIN_DRIVER) {
if (!row.fromYardId || !row.toYardId || !row.dutyRole) {
throw new BadRequestException(
`Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`,
);
}
// §1.1 territorial boundary is structural — never persist a breach.
const from = corridor.yards?.get(row.fromYardId);
const to = corridor.yards?.get(row.toYardId);
if (
!legAllowsNationality(
from,
to,
member.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
)
) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(TrainCrewAssignment);
await repo.delete({ trainScheduleId: scheduleId });
if (rows.length) {
await repo.insert(
rows.map((row) => ({
trainScheduleId: scheduleId,
crewMemberId: row.crewMemberId,
role: row.role,
dutyRole: row.dutyRole ?? null,
fromYardId: row.fromYardId ?? null,
toYardId: row.toYardId ?? null,
status: CrewAssignmentStatus.PLANNED,
notes: row.notes ?? null,
})),
);
}
});
const demand = await this.detectDemand(scheduleId);
const saved = await this.listForSchedule(scheduleId);
return validateCrewComposition(saved.map(toFacts), demand, corridor);
}
/**
* Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule
* listed, so staff see the whole gap at once rather than one error per retry.
*/
async assertCrewReadyForDispatch(scheduleId: string): Promise<void> {
const { validation } = await this.getScheduleCrew(scheduleId);
if (!validation.complete) {
throw new BadRequestException(
`Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`,
);
}
}
/** Roster drivers eligible for a leg between two yards (§1.1). */
async eligibleDrivers(
scheduleId: string,
fromYardId?: string,
toYardId?: string,
): Promise<TrainCrewMember[]> {
const drivers = await this.memberRepo.find({
where: {
role: TrainCrewRole.TRAIN_DRIVER,
status: TrainCrewStatus.ACTIVE,
isActive: true,
},
order: { firstName: 'ASC' },
});
if (!fromYardId || !toYardId) return drivers;
const corridor = await this.loadCorridor(scheduleId);
const from = corridor.yards?.get(fromYardId);
const to = corridor.yards?.get(toYardId);
return drivers.filter((d) =>
legAllowsNationality(
from,
to,
d.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
),
);
}
}
/** Reduce a persisted assignment to the facts the rules read. */
const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({
crewMemberId: a.crewMemberId,
role: a.role,
dutyRole: a.dutyRole,
fromYardId: a.fromYardId,
toYardId: a.toYardId,
nationality: a.crewMember?.nationality ?? '',
memberName: a.crewMember
? `${a.crewMember.firstName} ${a.crewMember.lastName}`
: 'A crew member',
});

View File

@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewService } from './train-crew.service';
@ApiTags('train-crew')
@ApiBearerAuth()
@Controller('train-crew')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.trainCrew.view,
FREIGHT_PERMS.trainCrew.create,
FREIGHT_PERMS.trainCrew.update,
FREIGHT_PERMS.trainCrew.delete,
])
export class TrainCrewController {
constructor(private readonly trainCrewService: TrainCrewService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.trainCrew.create)
@ApiOperation({ summary: 'Create a train crew member' })
create(@Body() dto: CreateTrainCrewMemberDto) {
return this.trainCrewService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List train crew members with filters' })
findAll(@Query() query: QueryTrainCrewMemberDto) {
return this.trainCrewService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get a train crew member by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.findById(id);
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.update)
@ApiOperation({ summary: 'Update a train crew member' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainCrewMemberDto,
) {
return this.trainCrewService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.delete)
@ApiOperation({ summary: 'Delete a train crew member' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.remove(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainCrewAssignment } from './entities/train-crew-assignment.entity';
import { TrainCrewMember } from './entities/train-crew-member.entity';
import { TrainCrewAssignmentController } from './train-crew-assignment.controller';
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
import { TrainCrewController } from './train-crew.controller';
import { TrainCrewService } from './train-crew.service';
@Module({
imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])],
providers: [TrainCrewService, TrainCrewAssignmentService],
controllers: [TrainCrewController, TrainCrewAssignmentController],
exports: [TrainCrewService, TrainCrewAssignmentService],
})
export class TrainCrewModule {}

View File

@@ -0,0 +1,111 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewMember } from './entities/train-crew-member.entity';
const DEFAULT_LIMIT = 25;
@Injectable()
export class TrainCrewService {
constructor(
@InjectRepository(TrainCrewMember)
private readonly crewRepo: Repository<TrainCrewMember>,
) {}
async create(dto: CreateTrainCrewMemberDto): Promise<TrainCrewMember> {
await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role);
const member = this.crewRepo.create(dto);
return this.crewRepo.save(member);
}
async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}> {
const page = query.page ?? 1;
const limit = query.limit ?? DEFAULT_LIMIT;
const qb = this.crewRepo.createQueryBuilder('c');
if (query.search) {
qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', {
search: `%${query.search}%`,
});
}
if (query.role) qb.andWhere('c.role = :role', { role: query.role });
if (query.nationality) {
qb.andWhere('c.nationality = :nationality', { nationality: query.nationality });
}
if (query.status) qb.andWhere('c.status = :status', { status: query.status });
if (query.isActive !== undefined) {
qb.andWhere('c.isActive = :isActive', { isActive: query.isActive });
}
// sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here.
const [data, total] = await qb
.orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<TrainCrewMember> {
const member = await this.crewRepo.findOne({ where: { id } });
if (!member) {
throw new NotFoundException(`Train crew member ${id} not found`);
}
return member;
}
async update(id: string, dto: UpdateTrainCrewMemberDto): Promise<TrainCrewMember> {
const member = await this.findById(id);
const firstName = dto.firstName ?? member.firstName;
const lastName = dto.lastName ?? member.lastName;
const role = dto.role ?? member.role;
const identityChanged =
firstName !== member.firstName ||
lastName !== member.lastName ||
role !== member.role;
if (identityChanged) {
await this.assertNoDuplicate(firstName, lastName, role, id);
}
Object.assign(member, dto);
return this.crewRepo.save(member);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.crewRepo.softDelete(id);
}
/**
* The roster carries no employee number yet, so name + role is the only
* identity available to catch an accidental re-entry of the same person.
* Case-insensitive; `exceptId` skips the row being updated.
*/
private async assertNoDuplicate(
firstName: string,
lastName: string,
role: string,
exceptId?: string,
): Promise<void> {
const existing = await this.crewRepo.findOne({
where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never },
});
if (existing && existing.id !== exceptId) {
throw new ConflictException(
`Train crew member ${firstName} ${lastName} (${role}) already exists`,
);
}
}
}

View File

@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
return new Date(departure.getTime() - offsetMinutes * 60_000);
}
/** The schedule fields the close-offset reopen guard reads. */
export interface CloseOffsetReopenSchedule {
status: string;
direction?: string | null;
windowPhase?: string | null;
bookingWindowStatus?: string | null;
scheduledDepartureDate?: Date | null;
}
export interface CloseOffsetReopenCheck {
/** True when shortening the close offset is the one thing that reopens booking. */
eligible: boolean;
/** Why the schedule is not eligible; null when it is. */
reason: string | null;
/** Minutes before departure this schedule currently stops taking bookings. */
offsetMinutes: number | null;
/** The cutoff that shut booking (departure offset); null without an offset. */
cutoffAt: Date | null;
}
/**
* Is this schedule's booking shut ONLY because of its close offset? That is the
* one case staff may fix from the board by shortening the offset (3 days → 1
* day, 2 hours, …) so the desk reopens before departure. Every other way a
* window ends stays closed: the train departed, it is full, it never had an
* offset (booking ran until departure), or the window is still mid-cycle.
*
* The last guard — "a cycle would fit before departure with no offset at all" —
* is what makes the offset the ONLY problem: when the desk's next opening lands
* after the train leaves, no offset change can help.
*/
export function closeOffsetReopenCheck(
schedule: CloseOffsetReopenSchedule,
cfg: {
importCloseOffsetMinutes?: number | null;
exportCloseOffsetMinutes?: number | null;
windowOpenHour: number;
windowCloseHour: number;
},
now: Date,
): CloseOffsetReopenCheck {
const departure = schedule.scheduledDepartureDate ?? null;
const offsetRaw =
schedule.direction === 'EXPORT'
? cfg.exportCloseOffsetMinutes
: cfg.importCloseOffsetMinutes;
const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null;
const cutoffAt =
departure && offsetMinutes != null
? bookingCloseCutoff(departure, schedule.direction, cfg)
: null;
const no = (reason: string): CloseOffsetReopenCheck => ({
eligible: false,
reason,
offsetMinutes,
cutoffAt,
});
if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') {
return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`);
}
if (!departure || departure.getTime() <= now.getTime()) {
return no('This train has already departed (or has no departure date).');
}
if (offsetMinutes == null) {
return no(
'This train has no close offset — booking ran until departure, so there is nothing to shorten.',
);
}
if (schedule.windowPhase !== 'DONE') {
return no(
schedule.windowPhase == null
? 'This train does not run a managed booking window.'
: `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`,
);
}
if (schedule.bookingWindowStatus === 'FULL') {
return no(
'Booking closed because the train is full, not because of the close offset.',
);
}
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
if (nextCycleOpensAt(now, hours, departure) == null) {
return no(
'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.',
);
}
return { eligible: true, reason: null, offsetMinutes, cutoffAt };
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;

View File

@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
@@ -871,7 +872,7 @@ export class TrainSchedulingController {
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
"Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/close-offset")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure",
})
async reduceScheduleCloseOffset(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReduceScheduleCloseOffsetDto,
) {
await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/schedule-date")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* Shorten the booking-close offset of ONE schedule whose booking shut only
* because of that offset (staff action on the ops board). The value replaces the
* schedule's frozen offset; 0 means "close at departure".
*/
export class ReduceScheduleCloseOffsetDto {
@ApiProperty({
example: 120,
description:
'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.',
})
@Type(() => Number)
@IsInt()
@Min(0)
closeOffsetMinutes!: number;
}

View File

@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* LEGACY — `max_train_length_meters`, `max_train_weight_tons` and
* `max_20ft_container_weight_tons` are no longer read by planning: train
* weight/length come from locomotive configuration and per-box ceilings from
* the rule engine's weight limit rules (`max_capacity_tons`). Kept only so
* existing rows keep loading.
*/
@Column({
name: 'max_train_length_meters',
type: 'numeric',

View File

@@ -0,0 +1,266 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { closeOffsetReopenCheck } from './batch-window.util';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* "Reopen booking by shortening the close offset": a train whose booking shut
* ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its
* window re-armed. Every other closed state is refused. Pure guard first, then
* the service against stub repositories.
*/
describe('close-offset reopen', () => {
// Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT.
const DEPARTURE = new Date('2026-09-09T07:00:00.000Z');
// Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff.
const NOW = new Date('2026-09-07T06:00:00.000Z');
const THREE_DAYS = 3 * 1_440;
const cfg = {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
importCloseOffsetMinutes: THREE_DAYS,
exportCloseOffsetMinutes: THREE_DAYS,
};
const closedByOffset = (over: Record<string, unknown> = {}) => ({
id: 'S1',
reference: 'S-2026-00001',
status: 'SCHEDULED',
direction: 'IMPORT',
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
scheduledDepartureDate: DEPARTURE,
originStationId: 'Y-ADD',
destinationStationId: 'Y-DJ',
ruleWindowOpenHour: 8,
ruleWindowCloseHour: 17,
ruleWindowDurationHours: 3,
ruleImportWindowLeadDays: 3,
ruleExportBookingLeadHours: 24,
ruleImportCloseOffsetMinutes: THREE_DAYS,
ruleExportCloseOffsetMinutes: THREE_DAYS,
...over,
});
describe('closeOffsetReopenCheck', () => {
it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => {
const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(THREE_DAYS);
expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z');
});
it.each([
['dispatched train', { status: 'DISPATCHED' }, /dispatched/i],
['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i],
['full train', { bookingWindowStatus: 'FULL' }, /full/i],
['window still open', { windowPhase: 'OPEN' }, /not closed yet/i],
['legacy row with no window', { windowPhase: null }, /managed booking window/i],
])('refuses a %s', (_label, over, reason) => {
const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(reason);
});
it('refuses when the schedule never had an offset (booking ran to departure)', () => {
const check = closeOffsetReopenCheck(
closedByOffset(),
{ ...cfg, importCloseOffsetMinutes: null },
NOW,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/no close offset/i);
expect(check.cutoffAt).toBeNull();
});
it('refuses when the desk could not reopen before departure even with no offset', () => {
// Tuesday 18:00 EAT, desk 817: next opening is Wednesday 08:00, but the
// train departs Wednesday 07:00 EAT — the offset is not the blocker.
const lateNow = new Date('2026-09-08T15:00:00.000Z');
const earlyDeparture = new Date('2026-09-09T04:00:00.000Z');
const check = closeOffsetReopenCheck(
closedByOffset({ scheduledDepartureDate: earlyDeparture }),
cfg,
lateNow,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/would not reopen before departure/i);
});
it('reads the export offset for an EXPORT schedule', () => {
const check = closeOffsetReopenCheck(
closedByOffset({ direction: 'EXPORT' }),
{ ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 },
NOW,
);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(120);
});
});
describe('TrainSchedulingService.reduceScheduleCloseOffset', () => {
type Fixture = {
schedule: Record<string, unknown> | null;
siblings?: Record<string, unknown>[];
now?: Date;
};
const makeService = (fx: Fixture) => {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const repo = {
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
}),
};
const siblingsQb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(fx.siblings ?? []),
};
const dataSource = {
getRepository: jest.fn().mockReturnValue(repo),
manager: {
getRepository: jest
.fn()
.mockReturnValue({ createQueryBuilder: () => siblingsQb }),
},
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
const emitted: string[] = [];
Object.assign(service, {
dataSource,
trainSchedulesRepository: {
findById: jest.fn().mockResolvedValue(fx.schedule),
},
getWindowConfig: jest.fn().mockResolvedValue(cfg),
emitWindowState: jest.fn().mockImplementation(async (id: string) => {
emitted.push(id);
}),
logger: { log: jest.fn(), warn: jest.fn() },
});
jest.useFakeTimers().setSystemTime(fx.now ?? NOW);
return { service, updates, emitted };
};
afterEach(() => jest.useRealTimers());
it('404s on an unknown schedule', async () => {
const { service } = makeService({ schedule: null });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses a train whose window is not shut by its offset', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ bookingWindowStatus: 'FULL' }),
});
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toThrow(/full/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset that is not shorter than the current one', async () => {
const { service, updates } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }),
).rejects.toThrow(/shorter than the current 3 days/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset whose new cutoff is still before the next desk opening', async () => {
// 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a
// cycle fits… but 2 days 1 hour (Mon 09:00) does not.
const { service } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', {
closeOffsetMinutes: 2 * 1_440 + 60,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => {
const { service, updates, emitted } = makeService({ schedule: closedByOffset() });
// 1 day before departure → new cutoff Tuesday 10:00 EAT.
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates).toHaveLength(1);
const [{ id, patch }] = updates;
expect(id).toBe('S1');
expect(patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowRuleCustom: true,
windowPhase: 'PRE_WINDOW',
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
// Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z).
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z');
expect(emitted).toEqual(['S1']);
});
it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => {
// Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close.
const tueAfternoon = new Date('2026-09-08T13:00:00.000Z');
const { service, updates } = makeService({
schedule: closedByOffset(),
now: tueAfternoon,
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 });
const [{ patch }] = updates;
expect(patch.ruleImportCloseOffsetMinutes).toBeNull();
expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString());
// Desk close (17:00 EAT = 14:00Z) ends the cycle before departure.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z');
});
it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => {
const { service, updates } = makeService({
schedule: closedByOffset(),
siblings: [
closedByOffset({ id: 'S2' }),
// Already full — booking did not close because of the offset.
closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }),
// Still mid-cycle — must keep the state its customers see.
closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }),
],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']);
expect(updates[1].patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowPhase: 'PRE_WINDOW',
});
});
it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ direction: 'EXPORT' }),
siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 });
expect(updates).toHaveLength(1);
const [{ patch }] = updates;
expect(patch).toMatchObject({
ruleExportCloseOffsetMinutes: 120,
windowPhase: 'PRE_WINDOW',
});
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
// Departure 07:00Z 2h.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z');
});
});
});

View File

@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
});
});
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
const ceilings = (bookings: unknown[]) =>
(
service as never as {
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
}
).containerCapacityCeilingsByLine(bookings);
it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => {
const find = jest.fn().mockResolvedValue([
{ containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' },
{ containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' },
{ containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null },
]);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WeightLimitRule) return { find };
throw new Error('unexpected repository');
});
const result = await ceilings([
{
tradeDirection: 'EXPORT',
bookingContainers: [
{ id: 'line-a', containerTypeId: 'ct-20' },
{ id: 'line-b', containerTypeId: 'ct-40' },
],
},
{ tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] },
]);
expect(result).toEqual({ 'line-a': 26, 'line-c': 28 });
expect(find).toHaveBeenCalledTimes(1);
});
it('queries nothing when the bookings carry no container lines', async () => {
dataSource.getRepository.mockImplementation(() => {
throw new Error('should not be called');
});
await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({});
});
});
});

View File

@@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
@@ -74,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
import {
buildWagonListWorkbook,
groupWagonListLines,
WagonListLine,
} from '../utils/wagon-list-workbook.util';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -111,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
@@ -122,6 +112,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
@@ -159,6 +150,7 @@ import {
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type ContainerPlacementRules,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import {
@@ -189,6 +181,8 @@ import {
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
MAX_FALLBACK_LENGTH,
MAX_FALLBACK_WEIGHT,
WagonTypeDimensions,
} from '../train-capacity.util';
import {
@@ -204,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
closeOffsetReopenCheck,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
nextCycleOpensAt,
shiftEatDay,
type OfficeHours,
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
@@ -246,6 +243,19 @@ const HANDLING_FIELDS = [
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
function describeMinutes(minutes: number): string {
if (minutes % 1_440 === 0) {
const d = minutes / 1_440;
return `${d} day${d === 1 ? '' : 's'}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hour${h === 1 ? '' : 's'}`;
}
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -273,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
};
}
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
return {
eligible: check.eligible,
reason: check.reason,
offsetMinutes: check.offsetMinutes,
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
@@ -378,13 +398,13 @@ export interface UnassignedBookingsResponse {
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
/**
* Train weight/length come from locomotive configuration (the assigned set, or
* the strongest in-service locomotive when none is assigned yet); per-box
* container ceilings come from the rule engine's weight limit rules. Only the
* 20ft pair-imbalance tolerance is a static default.
*/
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
@@ -446,9 +466,10 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
// keep working; production always resolves it.
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -809,9 +830,9 @@ export class TrainSchedulingService {
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
* Train length/weight and the 20ft weight cap columns are legacy: planning
* now takes weight/length from locomotive configuration and per-box ceilings
* from weight limit rules. They are neither read nor exposed here.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
@@ -1030,6 +1051,152 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
* because of that offset, and re-arm its window so the desk reopens. A 3-day
* offset that closed booking with the train still days away can be cut to a
* day or a couple of hours; the window then opens at the next desk opening
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
*
* Refused for every other kind of closed window (departed, full, no offset,
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
* shorter than the current one and must leave room for a cycle before the
* new cutoff. The offset is frozen onto the schedule (the global value is
* untouched) and the row is marked custom so a later global-rules save does
* not re-stamp it.
*
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
* sibling that is likewise shut only by its offset, so the group keeps its
* single shared timeline (each capped at its own new cutoff). EXPORT windows
* are per-train, so an export change touches only this schedule.
*/
async reduceScheduleCloseOffset(
id: string,
dto: ReduceScheduleCloseOffsetDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
const liveCfg = await this.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const check = closeOffsetReopenCheck(schedule, cfg, now);
if (!check.eligible || check.offsetMinutes == null) {
throw new BadRequestException(
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
);
}
const newOffset = dto.closeOffsetMinutes;
if (newOffset >= check.offsetMinutes) {
throw new BadRequestException(
`The new close offset must be shorter than the current ${describeMinutes(
check.offsetMinutes,
)} before departure.`,
);
}
const isExport = schedule.direction === 'EXPORT';
// 0 is stored as null so "no offset" keeps its single canonical value.
const offsetPatch = isExport
? { ruleExportCloseOffsetMinutes: newOffset || null }
: { ruleImportCloseOffsetMinutes: newOffset || null };
const merged: BookingWindowConfig = {
...cfg,
...(isExport
? { exportCloseOffsetMinutes: newOffset || null }
: { importCloseOffsetMinutes: newOffset || null }),
};
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
merged,
);
// The desk reopens at the next office-hours opening (now, when it is open),
// exactly as a reopen cycle would — and only if that lands before the cutoff.
const opensAt = nextCycleOpensAt(now, hours, cutoff);
if (opensAt == null) {
throw new BadRequestException(
'Even with this offset the desk would not reopen before booking closes again ' +
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
);
}
let closesAt: Date;
if (isExport) {
// Export runs one FCFS window: from the reopen until the cutoff.
closesAt = cutoff;
} else {
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
const cap = (d: Date, bound: Date): Date =>
d.getTime() > bound.getTime() ? bound : d;
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
if (!isExport) {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
const sibCfg = effectiveWindowConfig(sib, liveCfg);
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
// Only a sibling that is ALSO shut purely by an offset longer than the
// new one joins in; anything else keeps the state its customers saw.
if (
!sibCheck.eligible ||
sibCheck.offsetMinutes == null ||
sibCheck.offsetMinutes <= newOffset ||
!sib.scheduledDepartureDate
) {
continue;
}
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
...sibCfg,
importCloseOffsetMinutes: newOffset || null,
});
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
targets.push({ id: sib.id, cutoff: sibCutoff });
}
}
const repo = this.dataSource.getRepository(TrainSchedule);
for (const t of targets) {
await repo.update(t.id, {
...offsetPatch,
// Staff-set — exempt from the global re-stamp.
windowRuleCustom: true,
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
// the normal cycle from there (bookingWindowStatus flips OPEN then).
windowPhase: 'PRE_WINDOW',
windowOpensAt: cap(opensAt, t.cutoff),
windowClosesAt: cap(closesAt, t.cutoff),
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
}
this.logger.log(
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
`${check.offsetMinutes}${newOffset} min before departure` +
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
@@ -3027,6 +3194,11 @@ export class TrainSchedulingService {
'End the loading window at the origin station before dispatching',
);
}
// On-board crew must be complete before the train leaves — ITLMS Rolling
// Stock §1.2 enforces composition "prior to departure", so an incomplete
// crew saves freely on the assignment page but cannot depart. Optional
// dependency: the positional spec constructors omit it.
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
@@ -3770,15 +3942,15 @@ export class TrainSchedulingService {
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
* The schedule detail page's wagon-list Excel export, laid out like the
* wagon sheet the yard circulates by hand: containers grouped by customer,
* one line per container (a two-box wagon repeats its wagon number under one
* "No."), a blank line between customers, and the wagon count / company /
* transitor merged down each group. See buildWagonListWorkbook.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
* on the consist are omitted. A bulk wagon yields one line carrying the cargo
* description in place of a container number.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
@@ -3787,56 +3959,37 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
// container-less) allocation as one row. The transitor is the customs
// clearing agent the customer named on the booking.
const lines: WagonListLine[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
) AS "customerName",
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
@@ -3848,47 +4001,13 @@ export class TrainSchedulingService {
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
const { groups, totalWagons } = groupWagonListLines(lines);
const buffer = await buildWagonListWorkbook({
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
groups,
totalWagons,
});
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
@@ -6367,8 +6486,11 @@ export class TrainSchedulingService {
skip,
take,
});
// Live window config: each row's frozen rule overlays it to decide whether
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
const liveCfg = await this.getWindowConfig();
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
@@ -6708,11 +6830,6 @@ export class TrainSchedulingService {
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
@@ -6745,6 +6862,11 @@ export class TrainSchedulingService {
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
const placementRules: ContainerPlacementRules = {
maxContainerWeightTonsByLineId:
await this.containerCapacityCeilingsByLine(containerBookings),
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
violations.push(
...validateContainerPlacements(
containerBookings,
@@ -6892,6 +7014,70 @@ export class TrainSchedulingService {
}
}
/**
* Hard per-box ceiling for every container line of the given bookings, from
* the rule engine's weight limit rule (`max_capacity_tons`) matching the
* line's container type and the booking's trade direction (a `BOTH` rule
* applies to either direction; an exact-direction rule wins over it). Lines
* whose rule has no capacity set get no entry — capacity is optional.
*/
private async containerCapacityCeilingsByLine(
bookings: Booking[],
): Promise<Record<string, number>> {
const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = [];
for (const booking of bookings) {
const direction = String(booking.tradeDirection ?? '').toUpperCase();
for (const line of booking.bookingContainers ?? []) {
if (!line.containerTypeId) continue;
lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction });
}
}
if (!lines.length) return {};
const typeIds = [...new Set(lines.map((l) => l.containerTypeId))];
const rules = await this.dataSource
.getRepository(WeightLimitRule)
.find({ where: { containerTypeId: In(typeIds) } });
const ceilings: Record<string, number> = {};
for (const { lineId, containerTypeId, tradeDirection } of lines) {
const candidates = rules.filter(
(r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null,
);
const rule =
candidates.find((r) => r.tradeDirection === tradeDirection) ??
candidates.find((r) => r.tradeDirection === 'BOTH');
const cap = Number(rule?.maxCapacityTons);
if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap;
}
return ceilings;
}
/**
* Limits for a train that has no locomotive assigned yet: the strongest
* in-service locomotive on each axis, so planning assumes the most capable
* power that could be coupled. Null when no locomotive is configured at all.
*/
private async strongestFleetLocomotiveLimits(): Promise<LocomotiveLimits | null> {
const fleet = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
});
const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0);
const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0);
if (!pulls.length && !lengths.length) return null;
const strongest = (axis: number[], pick: (l: Locomotive) => number) =>
fleet.find((l) => pick(l) === Math.max(...axis));
return {
maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity,
maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity,
overageToleranceTons:
Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0,
overageToleranceMeters:
Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) ||
0,
};
}
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
@@ -6902,24 +7088,14 @@ export class TrainSchedulingService {
builtWagonCount?: number,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
'app.trainScheduling',
);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
const max20ftPairWeightDiffTons = this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
);
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
@@ -6954,52 +7130,40 @@ export class TrainSchedulingService {
: builtWagonCount && builtWagonCount > 0
? builtWagonCount
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
// No locomotive on the set yet: plan against the strongest in-service
// locomotive's configuration. An explicit dto override still narrows it.
const fleet = await this.strongestFleetLocomotiveLimits();
if (!fleet) {
this.logger.warn(
'No in-service locomotive is configured — train weight/length limits fall back to ' +
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
);
}
const derived = deriveTrainCapacityFromLocomotive(
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
wagonTypes,
{
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {
maxWeightTons,
maxLengthMeters,
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}
@@ -8869,7 +9033,11 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
/** Live window config; when given, the row carries its close-offset reopen state. */
liveCfg?: BookingWindowConfig,
) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
@@ -8933,6 +9101,18 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
windowPhase: schedule.windowPhase ?? null,
// Whether booking shut ONLY because of the close offset — the board offers
// "shorten close offset" on exactly these rows.
closeOffsetReopen: liveCfg
? toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, liveCfg),
new Date(),
),
)
: null,
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
maxWagons: schedule.maxWagons ?? 0,
@@ -10977,6 +11157,14 @@ export class TrainSchedulingService {
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
// Shut only by its close offset? Drives the "shorten close offset" action.
closeOffsetReopen: toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, windowCfg),
new Date(),
),
),
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -10986,6 +11174,12 @@ export class TrainSchedulingService {
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
// The offsets this train actually runs under (its frozen snapshot, or
// the live global for a legacy row) — null = booking runs to departure.
importCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
exportCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export
@@ -13049,3 +13243,4 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
}
//

View File

@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -17,6 +16,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainCrewModule } from '../train-crew/train-crew.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
@@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
@@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module';
forwardRef(() => WarehousesModule),
RuleEngineModule,
forwardRef(() => ContractsModule),
TrainCrewModule,
],
controllers: [TrainSchedulingController],
providers: [

View File

@@ -0,0 +1,169 @@
import ExcelJS from 'exceljs';
import {
buildWagonListWorkbook,
groupWagonListLines,
WAGON_LIST_HEADERS,
WagonListLine,
wagonListSheetName,
} from './wagon-list-workbook.util';
const line = (overrides: Partial<WagonListLine>): WagonListLine => ({
sequenceNo: 1,
wagonNumber: 'ER0001',
containerNumber: 'CONT0000001',
containerSizeFt: 40,
loadType: 'CONTAINER',
bulkCargoDescription: null,
originLabel: 'DCT',
destinationLabel: 'GMP',
customerName: 'ABC transit',
transitor: null,
...overrides,
});
// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes,
// then a second customer's single wagon, and a bulk wagon for a third.
const fixture: WagonListLine[] = [
line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'CXDU1833620',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'TTNU1328287',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 3,
wagonNumber: 'ER0444',
containerNumber: 'ESLU0720200',
containerSizeFt: 20,
customerName: 'SYNTRANS LOGISTICS PLC',
}),
line({
sequenceNo: 4,
wagonNumber: 'ER0716',
containerNumber: null,
containerSizeFt: null,
loadType: 'BULK',
bulkCargoDescription: 'Wheat',
customerName: 'Baili food processing',
}),
];
describe('groupWagonListLines', () => {
it('groups by customer in first-appearance order and counts wagons, not containers', () => {
const { groups, totalWagons } = groupWagonListLines(fixture);
expect(groups.map((g) => g.companyName)).toEqual([
'ABC transit',
'SYNTRANS LOGISTICS PLC',
'Baili food processing',
]);
expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]);
expect(totalWagons).toBe(4);
});
it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]);
expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]);
expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]);
});
it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']);
expect(groups[0].transitor).toBe('Semuzu Transit');
expect(groups[2].lines[0]).toMatchObject({
containerNumber: 'Wheat',
containerType: 'Bulk',
});
expect(groups[2].transitor).toBe('');
});
it('files lines with no customer under a placeholder group', () => {
const { groups } = groupWagonListLines([line({ customerName: null })]);
expect(groups[0].companyName).toBe('—');
});
});
describe('wagonListSheetName', () => {
it('strips characters Excel forbids and caps at 31 characters', () => {
expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502');
expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31);
expect(wagonListSheetName('///')).toBe('Wagons');
});
});
describe('buildWagonListWorkbook', () => {
let sheet: ExcelJS.Worksheet;
beforeAll(async () => {
const grouped = groupWagonListLines(fixture);
const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped });
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
sheet = workbook.worksheets[0];
});
const cell = (address: string) => sheet.getCell(address).value;
const merged = (address: string) => sheet.getCell(address).isMerged;
it('opens with the banner (train + total wagons) merged across every column, then the headers', () => {
expect(sheet.name).toBe('V138U 8502');
expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/);
expect(merged('I1')).toBe(true);
expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]);
expect(sheet.getCell('A2').font?.bold).toBe(true);
});
it('lays each customer out as a contiguous block separated by a blank row', () => {
// Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili.
expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']);
expect(sheet.getRow(6).values).toEqual([]);
expect(cell('B7')).toBe('ER0444');
expect(sheet.getRow(8).values).toEqual([]);
expect(cell('B9')).toBe('ER0716');
expect(cell('C9')).toBe('Wheat');
expect(cell('G9')).toBe('Bulk');
});
it('prints "No." once per wagon, merged down a two-container wagon', () => {
expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]);
expect(merged('A4')).toBe(true);
expect(merged('A5')).toBe(true);
expect(merged('A3')).toBe(false);
expect(cell('A7')).toBe(3);
expect(cell('A9')).toBe(4);
});
it('merges wagon count, company and transitor down the whole customer block', () => {
expect(cell('D3')).toBe(2);
expect(cell('H3')).toBe('ABC transit');
expect(cell('I3')).toBe('Semuzu Transit');
for (const col of ['D', 'H', 'I']) {
expect(merged(`${col}3`)).toBe(true);
expect(merged(`${col}5`)).toBe(true);
}
expect(sheet.getCell('H3').font?.bold).toBe(true);
// A single-line block has nothing to merge.
expect(merged('H7')).toBe(false);
expect(cell('D7')).toBe(1);
expect(cell('I7')).toBeNull();
});
it('carries the route and container size on every line', () => {
expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']);
expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']);
});
});

View File

@@ -0,0 +1,219 @@
import ExcelJS from 'exceljs';
/**
* One loaded container (or one bulk load) on a wagon of the schedule — the
* input grain of the wagon-list workbook. A wagon carrying two boxes arrives
* as two lines sharing `sequenceNo`.
*/
export interface WagonListLine {
sequenceNo: number | null;
wagonNumber: string | null;
containerNumber: string | null;
/** 20 / 40 / 45 …; null for bulk or unknown. */
containerSizeFt: number | null;
loadType: string | null;
bulkCargoDescription: string | null;
originLabel: string | null;
destinationLabel: string | null;
customerName: string | null;
/** The customs clearing / transit agent named on the booking. */
transitor: string | null;
}
export interface WagonListGroupLine {
/** Sheet-wide wagon counter — printed once per wagon, not once per container. */
wagonOrdinal: number;
sequenceNo: number | null;
wagonNumber: string;
containerNumber: string;
containerType: string;
origin: string;
destination: string;
}
/** All lines of one customer, contiguous on the sheet. */
export interface WagonListGroup {
companyName: string;
transitor: string;
/** Distinct wagons in the group — the "Number of Wagons" cell. */
wagonCount: number;
lines: WagonListGroupLine[];
}
export interface WagonListWorkbookInput {
/** Train number (falls back to the schedule reference) — the banner text. */
trainLabel: string;
groups: WagonListGroup[];
totalWagons: number;
}
const BLANK = '—';
/**
* Groups the container-grain lines by customer, in order of first appearance,
* keeping consist order inside each group. Wagon ordinals run across the whole
* sheet so the reader can count wagons down the "No." column.
*/
export function groupWagonListLines(lines: WagonListLine[]): {
groups: WagonListGroup[];
totalWagons: number;
} {
const groups = new Map<
string,
WagonListGroup & { transitors: Set<string>; wagons: Set<string> }
>();
const ordinalByGroupWagon = new Map<string, number>();
let nextOrdinal = 1;
for (const line of lines) {
const companyName = line.customerName?.trim() || BLANK;
let group = groups.get(companyName);
if (!group) {
group = {
companyName,
transitor: '',
wagonCount: 0,
lines: [],
transitors: new Set(),
wagons: new Set(),
};
groups.set(companyName, group);
}
const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`;
const ordinalKey = `${companyName} ${wagonKey}`;
let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey);
if (wagonOrdinal === undefined) {
wagonOrdinal = nextOrdinal++;
ordinalByGroupWagon.set(ordinalKey, wagonOrdinal);
group.wagons.add(wagonKey);
}
const transitor = line.transitor?.trim();
if (transitor) group.transitors.add(transitor);
const isBulk = line.loadType === 'BULK' && !line.containerNumber;
group.lines.push({
wagonOrdinal,
sequenceNo: line.sequenceNo,
wagonNumber: line.wagonNumber ?? BLANK,
containerNumber:
line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK),
containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK,
origin: line.originLabel ?? BLANK,
destination: line.destinationLabel ?? BLANK,
});
}
const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({
...group,
transitor: [...transitors].join(', '),
wagonCount: wagons.size,
}));
return {
groups: result,
totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0),
};
}
const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9];
export const WAGON_LIST_HEADERS = [
'No.',
'Wagon',
'Container No.',
'Number of Wagons',
'Origin',
'Destination',
'Type of Container',
'Company Name',
'Transitor',
];
const LAST_COLUMN = WAGON_LIST_HEADERS.length;
/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */
const BANNER_FILL: ExcelJS.Fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFACB9CA' },
};
const CENTERED: Partial<ExcelJS.Alignment> = { horizontal: 'center', vertical: 'middle' };
/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */
export function wagonListSheetName(trainLabel: string): string {
const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim();
return (cleaned || 'Wagons').slice(0, 31);
}
/**
* The operations wagon-list sheet, laid out like the hand-made one the yard
* circulates: a banner row (train number + total wagons), one header row, then
* the containers grouped by customer with a blank row between customers.
* Inside a group the wagon number repeats per container while "No." is merged
* down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged
* down the whole group.
*/
export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), {
views: [{ zoomScale: 85 }],
});
COLUMN_WIDTHS.forEach((width, i) => {
sheet.getColumn(i + 1).width = width;
});
const banner = sheet.addRow([
`${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`,
]);
sheet.mergeCells(1, 1, 1, LAST_COLUMN);
banner.height = 28;
const bannerCell = banner.getCell(1);
bannerCell.font = { name: 'Calibri', size: 12, bold: true };
bannerCell.alignment = CENTERED;
bannerCell.fill = BANNER_FILL;
const header = sheet.addRow(WAGON_LIST_HEADERS);
header.eachCell((cell) => {
cell.font = { name: 'Calibri', size: 11, bold: true };
cell.alignment = CENTERED;
});
input.groups.forEach((group, groupIndex) => {
if (groupIndex > 0) sheet.addRow([]);
const firstRow = sheet.rowCount + 1;
let wagonStartRow = firstRow;
group.lines.forEach((line, lineIndex) => {
const isFirstLine = lineIndex === 0;
const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal;
const row = sheet.addRow([
newWagon ? line.wagonOrdinal : null,
line.wagonNumber,
line.containerNumber,
isFirstLine ? group.wagonCount : null,
line.origin,
line.destination,
line.containerType,
isFirstLine ? group.companyName : null,
isFirstLine ? group.transitor || null : null,
]);
for (let col = 1; col <= LAST_COLUMN; col++) {
const cell = row.getCell(col);
cell.font = { name: 'Calibri', size: 11, bold: col === 8 };
if (col === 8) cell.alignment = { ...CENTERED, wrapText: true };
else if (col !== 2 && col !== 3) cell.alignment = CENTERED;
}
row.getCell(1).numFmt = '#,##0';
if (newWagon && !isFirstLine) {
if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1);
wagonStartRow = row.number;
}
});
const lastRow = sheet.rowCount;
if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1);
if (lastRow > firstRow) {
for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col);
}
});
return Buffer.from(await workbook.xlsx.writeBuffer());
}

View File

@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
it('rejects a container over its line weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
});
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
maxContainerWeightTonsByLineId: {},
max20ftPairWeightDiffTons: 10,
});
expect(violations).toEqual([]);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});

View File

@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
maxWeightTons?: number;
maxLengthMeters?: number;
maxWagonsPerTrain?: number;
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
max20ftContainerWeightTons?: number;
/**
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
* line's container type and the booking's trade direction. A line with no
* entry has no ceiling — the rule's capacity is optional.
*/
maxContainerWeightTonsByLineId?: Record<string, number>;
max20ftPairWeightDiffTons?: number;
};
@@ -820,15 +825,21 @@ export function perEdgeConsistUsage(
);
}
/**
* Per-box weight rules for a container plan:
* - every unit is checked against its line's weight-limit-rule capacity
* ceiling (`maxContainerWeightTonsByLineId`, any size);
* - 20ft pairs sharing a wagon are checked for weight imbalance.
*/
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const maxEach = rules?.max20ftContainerWeightTons;
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (maxEach == null && maxDiff == null) return violations;
if (capacityByLine == null && maxDiff == null) return violations;
const placementByUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
const weightsBySlot = new Map<number, number[]>();
for (const unit of units) {
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const maxEach = capacityByLine?.[unit.bookingContainerId];
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
);
}
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.sequenceNo) continue;