mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge branch 'dev' into freight/feat/chat-app
This commit is contained in:
@@ -1034,16 +1034,16 @@ export class BillingService {
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
// if (!result.immediateSuccess) {
|
||||
// await this.payment.handlePaymentEvent({
|
||||
// eventType: "payment.succeeded",
|
||||
// eventId: `demo-${result.intentId}`,
|
||||
// referenceId: invoice.sourceId,
|
||||
// intentId: result.intentId,
|
||||
// providerTxnId: result.providerTxnId,
|
||||
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
// });
|
||||
// }
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
referenceId: invoice.sourceId,
|
||||
intentId: result.intentId,
|
||||
providerTxnId: result.providerTxnId,
|
||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
// Base freight is configured per leg, so every rate and every booking names the
|
||||
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
|
||||
const MOJO = 'yard-mojo';
|
||||
const DIRE = 'yard-dire-dawa';
|
||||
const LEBU = 'yard-lebu';
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
@@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerUsd: Rate = {
|
||||
@@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
@@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
|
||||
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
|
||||
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
|
||||
// nobody configured this one yet is worse than billing no base freight.
|
||||
it('does not price bulk off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-3',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not price containers off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-4',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,7 +475,14 @@ export class BookingPricingService {
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
const rate = this.pickRate(
|
||||
liveRates,
|
||||
rateType,
|
||||
container.containerTypeId,
|
||||
'USD',
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
@@ -515,8 +522,15 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
// Bulk (and any booking with no container lines) still has to price off a
|
||||
// rate configured for this leg — never one belonging to another route.
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === 'USD' &&
|
||||
r.status === 'LIVE' &&
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
@@ -711,20 +725,32 @@ export class BookingPricingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base freight is quoted per leg, so a rate only applies to a booking running
|
||||
* the exact origin → destination it was configured for. There is deliberately
|
||||
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
|
||||
* because nobody configured Mojo yet is worse than surfacing no line at all.
|
||||
* Within the leg, a rate scoped to the container type wins over one that
|
||||
* covers every type.
|
||||
*/
|
||||
private pickRate(
|
||||
rates: Rate[],
|
||||
rateType: string,
|
||||
containerTypeId: string,
|
||||
currency: string,
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
): Rate | undefined {
|
||||
const onLeg = rates.filter(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === currency &&
|
||||
r.originYardId === originYardId &&
|
||||
r.destinationYardId === destinationYardId,
|
||||
);
|
||||
return (
|
||||
rates.find(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === currency &&
|
||||
r.containerTypeId === containerTypeId,
|
||||
) ??
|
||||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||||
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
@@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Export over-book gate at the customer's requestOperation step: export never
|
||||
* splits, so the free-space check runs the moment the customer commits to a
|
||||
* shipment day. When no single export train that day can carry the whole
|
||||
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
|
||||
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
|
||||
* here (they are batched + splittable later).
|
||||
*/
|
||||
describe('BookingTransitionService — requestOperation export space gate', () => {
|
||||
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
reference: 'BKG-1',
|
||||
status: 'CLEARANCE_READY',
|
||||
tradeDirection,
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
totalAmount: 1000,
|
||||
contractId: null,
|
||||
serviceType: { code: 'RAIL_CONTAINER' },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
checkDayCompatibilityForBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
// Over-book → the export gate rejects; otherwise it returns a schedule id.
|
||||
pickExportSchedule: overbook
|
||||
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
|
||||
: jest.fn().mockResolvedValue('sched-1'),
|
||||
};
|
||||
const notifier = { operationRequestedToStaff: 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,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
notifier as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('rejects an over-booked export request and does NOT advance the booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an export request through when a train fits the whole booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
false,
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('never runs the export gate for an import request', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'IMPORT',
|
||||
true, // would reject IF called — proves it is not called
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1036,6 +1036,22 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
// Export is FCFS and never splits — a booking must ride one train whole. So
|
||||
// the free-space check belongs HERE, the moment the customer commits to a
|
||||
// shipment day, not later at staff operation-accept. Blocking now stops the
|
||||
// customer booking more wagons than any single export train that day can
|
||||
// still carry; `exportSpaceReport` throws a 409 whose message carries the
|
||||
// largest bookable leftover ("reduce to N wagons or pick another day").
|
||||
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
||||
// here — they get an advisory count below and the batch engine sizes them.
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
scheduledDate: date,
|
||||
@@ -1045,6 +1061,47 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory availability for a shipment day the customer is considering — a
|
||||
* planning hint for the day picker, computed but never enforced. For EXPORT it
|
||||
* mirrors the real request-time gate: `fits` is whether a single open train
|
||||
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
|
||||
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
|
||||
* TOTAL room across the day's trains for the booking's wagon type (the batch
|
||||
* engine may still split or defer a remainder), and `fits` is whether that
|
||||
* total covers the booking. `trainsForDay` is false when no departure carries
|
||||
* the leg — the day is unbookable regardless of space.
|
||||
*/
|
||||
async dayAvailabilityForBooking(
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
const day = eatDay(date);
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
|
||||
if (isExportTrain) {
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const report =
|
||||
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
|
||||
return {
|
||||
fits: report.scheduleId != null,
|
||||
freeWagons: report.bestAvailable?.wagons ?? 0,
|
||||
trainsForDay: report.trainsForDay && report.corridorMatched,
|
||||
};
|
||||
}
|
||||
|
||||
const { freeWagons, need, trainsForDay } =
|
||||
await this.bookingBatchService.dayImportAvailability(booking, day);
|
||||
return { fits: freeWagons >= need, freeWagons, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations team reviews a pending operation request (capacity, documents,
|
||||
* route). Two outcomes:
|
||||
|
||||
@@ -370,6 +370,31 @@ export class BookingsController {
|
||||
return this.bookingsService.availableDaysForBooking(id);
|
||||
}
|
||||
|
||||
@Get(':id/day-availability')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
|
||||
'Export: whole-booking fit + largest single-train leftover. ' +
|
||||
'Import/domestic: total room across the day for the booking\'s wagon type.',
|
||||
})
|
||||
async dayAvailability(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('date') date: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.dayAvailabilityForBooking(id, date);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
@@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRateScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CustomerTruckAssignmentsRepository,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { RateSchedule } from "../../contracts/contract-rate-schedule.builder";
|
||||
import { getTemplateMeta } from "../../contracts/contract-template.registry";
|
||||
import {
|
||||
ContractDynamicTemplateView,
|
||||
@@ -177,17 +178,9 @@ export class ContractTemplatesService {
|
||||
const isBulk = code.endsWith("_BULK");
|
||||
const now = new Date();
|
||||
|
||||
const unitRates = isBulk
|
||||
? [
|
||||
{ label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" },
|
||||
{ label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" },
|
||||
{ label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" },
|
||||
]
|
||||
: [
|
||||
{ label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" },
|
||||
{ label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" },
|
||||
{ label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" },
|
||||
];
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
// table shape. Real contracts populate this from freight.rates (LIVE).
|
||||
const rateSchedule = this.mockRateSchedule(code, isBulk);
|
||||
|
||||
return {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
@@ -239,13 +232,16 @@ export class ContractTemplatesService {
|
||||
lastMileDeliveryAddress: "—",
|
||||
},
|
||||
pricing: {
|
||||
displayMode: "UNIT_RATES",
|
||||
unitRates,
|
||||
lineItems: [],
|
||||
surcharges: [],
|
||||
totalAmount: 0,
|
||||
currency: "USD",
|
||||
equipmentReturn: isBulk ? "—" : "With empty return",
|
||||
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
||||
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
||||
containerLines: [],
|
||||
} as unknown as ContractViewModel["pricing"],
|
||||
rateSchedule,
|
||||
signatures: [],
|
||||
canSignCustomer: false,
|
||||
canSignStaff: false,
|
||||
@@ -256,6 +252,43 @@ export class ContractTemplatesService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
? "export"
|
||||
: "domestic";
|
||||
const lane =
|
||||
dir === "export"
|
||||
? "Galaan Multipurpose Port → SGTD"
|
||||
: dir === "domestic"
|
||||
? "Mojo Dry Port → Dire Dawa"
|
||||
: "Negad → Mojo Dry Port";
|
||||
|
||||
const freightLanes = isBulk
|
||||
? [
|
||||
{ route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" },
|
||||
]
|
||||
: [
|
||||
{ route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" },
|
||||
{ route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" },
|
||||
];
|
||||
|
||||
return {
|
||||
freightLanes,
|
||||
additionalServices: [
|
||||
{ route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" },
|
||||
{ route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" },
|
||||
],
|
||||
surcharges: [
|
||||
{ route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" },
|
||||
],
|
||||
isEmpty: false,
|
||||
currencyLabel: "USD",
|
||||
};
|
||||
}
|
||||
|
||||
private assertCode(code: string): ContractTemplateCode {
|
||||
const upper = code?.toUpperCase() as ContractTemplateCode;
|
||||
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
|
||||
|
||||
@@ -37,6 +37,14 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
requiresDirectorApproval?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'When true, bookings of this cargo type incur the flat LASHING surcharge.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasLashing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||
@@ -37,6 +38,31 @@ export class CreateRateDto {
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: INTERCITY_KINDS,
|
||||
description:
|
||||
'Whether an intercity rate covers containers or bulk. Required when appliesTo = INTERCITY; ignored otherwise. Not stored — it selects the INTERCITY_CONTAINER / INTERCITY_BULK rate type.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...INTERCITY_KINDS])
|
||||
intercityKind?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...CURRENCIES])
|
||||
|
||||
@@ -53,6 +53,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
/**
|
||||
* When true, any booking of this cargo type incurs the flat LASHING surcharge
|
||||
* (the LASHING-trigger rate). Set on commodities that need EDR-provided
|
||||
* lashing/securing; leave false for cargo that ships without it.
|
||||
*/
|
||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||
hasLashing!: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export function deriveRateType(input: {
|
||||
case 'SHIPPING_LINE':
|
||||
return 'DOUBLE_HANDLING';
|
||||
case 'CONSOLIDATION':
|
||||
case 'LASHING':
|
||||
return 'LASHING';
|
||||
case 'CANCELLATION':
|
||||
return 'CANCELLATION_FEE';
|
||||
|
||||
@@ -36,6 +36,9 @@ export function allowedRateUnits(input: {
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
||||
return ['FLAT'];
|
||||
case 'LASHING':
|
||||
// Flat cargo-securing fee, billed once per booking.
|
||||
return ['FLAT'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { CargoType } from './cargo-type.entity';
|
||||
import { ContainerType } from './container-type.entity';
|
||||
import { Yard } from './yard.entity';
|
||||
|
||||
export const RATE_TYPES = [
|
||||
'CONTAINER_IMPORT',
|
||||
@@ -77,6 +78,9 @@ export const RATE_TRIGGERS = [
|
||||
'WITH_RETURN',
|
||||
'SHIPPING_LINE',
|
||||
'CONSOLIDATION',
|
||||
// Cargo securing / lashing. Fires when the booking's cargo type has
|
||||
// hasLashing = true. Flat fee, billed once per booking.
|
||||
'LASHING',
|
||||
'CANCELLATION',
|
||||
'DEMURRAGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
@@ -91,6 +95,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
@Index(['status'])
|
||||
@Index(['containerTypeId'])
|
||||
@Index(['trigger'])
|
||||
@Index(['originYardId'])
|
||||
@Index(['destinationYardId'])
|
||||
export class Rate extends BaseEntity {
|
||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||
rateType!: RateType;
|
||||
@@ -118,6 +124,26 @@ export class Rate extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||||
tradeDirection?: string | null;
|
||||
|
||||
/**
|
||||
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||||
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||||
* required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
|
||||
* `CK_rates_yard_scope` DB constraint enforces both halves of that.
|
||||
*/
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId?: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||
destinationYardId?: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -6,12 +6,20 @@ import { Rate } from '../entities/rate.entity';
|
||||
export interface IRatesRepository {
|
||||
findById(id: string): Promise<Rate | null>;
|
||||
findLiveRates(): Promise<Rate[]>;
|
||||
/**
|
||||
* LIVE rates with the yard / container / cargo relations eagerly joined, so
|
||||
* lanes can be rendered with human labels (contract rate schedule). Ordered
|
||||
* for a stable, readable schedule table.
|
||||
*/
|
||||
findLiveRatesDetailed(): Promise<Rate[]>;
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<Rate | null>;
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||
|
||||
@@ -25,6 +25,22 @@ export class RatesRepository implements IRatesRepository {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findLiveRatesDetailed(): Promise<Rate[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('rate.containerType', 'containerType')
|
||||
.leftJoinAndSelect('rate.cargoType', 'cargoType')
|
||||
.where('rate.status = :status', { status: 'LIVE' })
|
||||
.orderBy('rate.appliesTo', 'ASC')
|
||||
.addOrderBy('rate.tradeDirection', 'ASC')
|
||||
.addOrderBy('originYard.label', 'ASC')
|
||||
.addOrderBy('destinationYard.label', 'ASC')
|
||||
.addOrderBy('rate.rateValue', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-superseded rate matching an identity pattern — the same tuple the
|
||||
* `UQ_rates_pattern` unique index enforces. Used to reject duplicates before
|
||||
@@ -37,6 +53,8 @@ export class RatesRepository implements IRatesRepository {
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<Rate | null> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
@@ -59,6 +77,18 @@ export class RatesRepository implements IRatesRepository {
|
||||
} else {
|
||||
qb.andWhere('rate.trade_direction IS NULL');
|
||||
}
|
||||
if (pattern.originYardId) {
|
||||
qb.andWhere('rate.origin_yard_id = :originYardId', { originYardId: pattern.originYardId });
|
||||
} else {
|
||||
qb.andWhere('rate.origin_yard_id IS NULL');
|
||||
}
|
||||
if (pattern.destinationYardId) {
|
||||
qb.andWhere('rate.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId: pattern.destinationYardId,
|
||||
});
|
||||
} else {
|
||||
qb.andWhere('rate.destination_yard_id IS NULL');
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
@@ -75,6 +105,10 @@ export class RatesRepository implements IRatesRepository {
|
||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
// The admin table shows the leg a base-freight rate prices — without the
|
||||
// yards joined the route columns have only ids to render.
|
||||
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
||||
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||
|
||||
if (query.status) {
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||
* engine from cargoTypeId when omitted.
|
||||
*/
|
||||
hasLashing?: boolean;
|
||||
totalWagons: number;
|
||||
/**
|
||||
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
|
||||
@@ -132,12 +138,22 @@ export class RuleEngineService {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
|
||||
// Lashing is a cargo-type property: a booking incurs the flat LASHING
|
||||
// surcharge when its cargo type has hasLashing = true. Resolve it here so
|
||||
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
||||
// input flag when no cargo type is set (e.g. container bookings).
|
||||
let hasLashing = input.hasLashing === true;
|
||||
if (input.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
||||
} else if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
} else {
|
||||
if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
if (cargoType.hasLashing) {
|
||||
hasLashing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +253,7 @@ export class RuleEngineService {
|
||||
hasOverweight,
|
||||
shippingLineMapped,
|
||||
allowConsolidation: input.allowConsolidation ?? false,
|
||||
hasLashing,
|
||||
});
|
||||
if (!triggered) continue;
|
||||
|
||||
@@ -466,6 +483,7 @@ export class RuleEngineService {
|
||||
hasOverweight: boolean;
|
||||
shippingLineMapped: boolean;
|
||||
allowConsolidation: boolean;
|
||||
hasLashing: boolean;
|
||||
},
|
||||
): boolean {
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
@@ -484,6 +502,8 @@ export class RuleEngineService {
|
||||
return truthy(state.shippingLineMapped);
|
||||
case 'CONSOLIDATION':
|
||||
return truthy(state.allowConsolidation);
|
||||
case 'LASHING':
|
||||
return truthy(state.hasLashing);
|
||||
// CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
|
||||
// explicitly elsewhere (not auto-triggered by a booking's cargo flags).
|
||||
default:
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
@@ -14,12 +14,24 @@ import { Rate } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
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'];
|
||||
|
||||
/** The yard pair a rate scopes to, already validated against its direction. */
|
||||
interface YardScope {
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RatesService {
|
||||
constructor(
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly repository: IRatesRepository,
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
) {}
|
||||
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
@@ -32,6 +44,14 @@ export class RatesService {
|
||||
return this.repository.findLiveRates();
|
||||
}
|
||||
|
||||
/**
|
||||
* LIVE rates with yard / container / cargo relations joined — used to render
|
||||
* the origin → destination rate schedule inside generated contracts.
|
||||
*/
|
||||
async findLiveRatesDetailed(): Promise<Rate[]> {
|
||||
return this.repository.findLiveRatesDetailed();
|
||||
}
|
||||
|
||||
/** Get a rate by ID. */
|
||||
async findById(id: string): Promise<Rate> {
|
||||
const entity = await this.repository.findById(id);
|
||||
@@ -62,6 +82,136 @@ export class RatesService {
|
||||
return requestedUnit;
|
||||
}
|
||||
|
||||
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
|
||||
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country each end of the leg must sit in, given what the rate is for.
|
||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||
* rails inland, export is the reverse, and intercity stays inside Ethiopia.
|
||||
*/
|
||||
private expectedYardCountries(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
tradeDirection: string | null,
|
||||
): { origin: YardCountry; destination: YardCountry } {
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
return tradeDirection === 'EXPORT'
|
||||
? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI }
|
||||
: { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalise the leg a rate prices.
|
||||
*
|
||||
* Base freight must name both yards and they must match the direction, so a
|
||||
* "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything
|
||||
* else (surcharges, first/last mile) is route-agnostic and has its yards
|
||||
* cleared, mirroring how container/cargo scope is cleared for surcharges.
|
||||
*/
|
||||
private async resolveYardScope(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
tradeDirection: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
const originYardId = input.originYardId ?? null;
|
||||
const destinationYardId = input.destinationYardId ?? null;
|
||||
if (!originYardId || !destinationYardId) {
|
||||
throw new BadRequestException(
|
||||
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
||||
);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
throw new BadRequestException('Origin and destination yard must be different.');
|
||||
}
|
||||
|
||||
const [origin, destination] = await Promise.all([
|
||||
this.yardsRepository.findById(originYardId),
|
||||
this.yardsRepository.findById(destinationYardId),
|
||||
]);
|
||||
if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||
if (!destination) {
|
||||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||
}
|
||||
|
||||
const expected = this.expectedYardCountries(appliesTo, tradeDirection);
|
||||
if (origin.country !== expected.origin || destination.country !== expected.destination) {
|
||||
const shape =
|
||||
appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`;
|
||||
throw new BadRequestException(
|
||||
`${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` +
|
||||
`${origin.country} and ${destination.label} is in ${destination.country}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { originYardId, destinationYardId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard the scope fields a base-freight category needs before we derive its
|
||||
* rateType: import/export must say which, and intercity must say whether it
|
||||
* carries containers or bulk (the two price differently and an unstated kind
|
||||
* would silently file the rate as one of them).
|
||||
*/
|
||||
private assertScopeCoherent(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
tradeDirection: string | null;
|
||||
intercityKind: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') {
|
||||
throw new BadRequestException(
|
||||
'An intercity rate must say whether it covers containers or bulk.',
|
||||
);
|
||||
}
|
||||
// The scope field has to agree with the kind, or the rate would advertise
|
||||
// one cargo kind and narrow by the other.
|
||||
if (intercityKind === 'CONTAINER' && cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An intercity container rate cannot be scoped to a bulk cargo type.',
|
||||
);
|
||||
}
|
||||
if (intercityKind === 'BULK' && containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An intercity bulk rate cannot be scoped to a container type.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a rate covers bulk cargo — the flag `deriveRateType` splits
|
||||
* INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind
|
||||
* explicitly; for BULK/CONTAINER the category already says it.
|
||||
*/
|
||||
private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean {
|
||||
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||
@@ -73,12 +223,14 @@ export class RatesService {
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
ignoreId?: string;
|
||||
}): Promise<void> {
|
||||
const existing = await this.repository.findByPattern(pattern);
|
||||
if (existing && existing.id !== pattern.ignoreId) {
|
||||
throw new ConflictException(
|
||||
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
|
||||
'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,17 +244,45 @@ export class RatesService {
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs.
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
||||
|
||||
const intercityKind = dto.intercityKind ?? null;
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection,
|
||||
intercityKind,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
});
|
||||
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
});
|
||||
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection,
|
||||
isBulk: Boolean(cargoTypeId),
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
|
||||
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
});
|
||||
|
||||
return this.repository.create({
|
||||
appliesTo,
|
||||
@@ -111,6 +291,8 @@ export class RatesService {
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
currency: dto.currency ?? 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit,
|
||||
@@ -194,21 +376,52 @@ export class RatesService {
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection = isSurcharge
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
|
||||
updates.containerTypeId = containerTypeId ?? null;
|
||||
updates.cargoTypeId = cargoTypeId ?? null;
|
||||
updates.tradeDirection = tradeDirection ?? null;
|
||||
|
||||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||
// has — read back off its rateType, the only place it is recorded.
|
||||
const intercityKind =
|
||||
dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER');
|
||||
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
intercityKind,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
});
|
||||
// Re-validate the leg: changing direction can invalidate a yard pair that
|
||||
// was legal under the old one (an import route is not an export route).
|
||||
const yardScope = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
originYardId:
|
||||
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||||
destinationYardId:
|
||||
dto.destinationYardId !== undefined
|
||||
? dto.destinationYardId
|
||||
: existing.destinationYardId,
|
||||
});
|
||||
updates.originYardId = yardScope.originYardId;
|
||||
updates.destinationYardId = yardScope.destinationYardId;
|
||||
|
||||
// Keep the derived rateType in sync with whatever changed.
|
||||
const rateType = deriveRateType({
|
||||
appliesTo,
|
||||
trigger,
|
||||
tradeDirection,
|
||||
isBulk: Boolean(cargoTypeId),
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
updates.rateType = rateType;
|
||||
|
||||
@@ -224,6 +437,8 @@ export class RatesService {
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
originYardId: updates.originYardId,
|
||||
destinationYardId: updates.destinationYardId,
|
||||
ignoreId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: string | null;
|
||||
|
||||
/**
|
||||
* Reverse the wagon ORDER on this train: when true, the built wagon plan is
|
||||
* flipped at build so the physically-last wagon sits at position 1. Only the
|
||||
* order (sequenceNo) changes — composition and allocations travel with their
|
||||
* slot. Frozen at create; every (re)assignment rebuilds under this flag so the
|
||||
* stored train order and the schedule order always match. Default false.
|
||||
*/
|
||||
@Column({ name: 'reverse_wagon_order', type: 'boolean', default: false })
|
||||
reverseWagonOrder!: boolean;
|
||||
|
||||
@Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true })
|
||||
actualDepartureAt?: Date | null;
|
||||
|
||||
@@ -149,6 +159,14 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
||||
ruleExportBookingLeadHours?: number | null;
|
||||
|
||||
/** Frozen import booking-close offset (minutes before departure). NULL = none. */
|
||||
@Column({ name: 'rule_import_close_offset_minutes', type: 'int', nullable: true })
|
||||
ruleImportCloseOffsetMinutes?: number | null;
|
||||
|
||||
/** Frozen export booking-close offset (minutes before departure). NULL = none. */
|
||||
@Column({ name: 'rule_export_close_offset_minutes', type: 'int', nullable: true })
|
||||
ruleExportCloseOffsetMinutes?: number | null;
|
||||
|
||||
// Frozen wagon plan captured once when the schedule leaves the editable
|
||||
// DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a
|
||||
// non-editable schedule read THIS instead of the live wagon↔slot joins, so the
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
listConfigBookingWindows,
|
||||
groupBookingsIntoBoardWindows,
|
||||
computeImportWindowTimes,
|
||||
computeExportWindowTimes,
|
||||
bookingCloseCutoff,
|
||||
type BoardWindowConfig,
|
||||
} from './batch-window.util';
|
||||
|
||||
@@ -360,3 +362,101 @@ describe('computeImportWindowTimes — immediate open inside the window day', ()
|
||||
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
// Booking-close offset: a configured offset pulls the window close earlier than
|
||||
// departure by that many minutes, separately for import and export.
|
||||
describe('bookingCloseCutoff — departure − offset', () => {
|
||||
const departure = new Date('2026-07-10T13:00:00.000Z'); // 16:00 EAT Jul 10
|
||||
|
||||
it('returns departure unchanged when no offset is set', () => {
|
||||
expect(bookingCloseCutoff(departure, 'IMPORT', {}).toISOString()).toBe(
|
||||
departure.toISOString(),
|
||||
);
|
||||
expect(
|
||||
bookingCloseCutoff(departure, 'EXPORT', {
|
||||
importCloseOffsetMinutes: 180,
|
||||
}).toISOString(),
|
||||
).toBe(departure.toISOString());
|
||||
});
|
||||
|
||||
it('a non-positive offset is treated as no offset', () => {
|
||||
expect(
|
||||
bookingCloseCutoff(departure, 'IMPORT', {
|
||||
importCloseOffsetMinutes: 0,
|
||||
}).toISOString(),
|
||||
).toBe(departure.toISOString());
|
||||
expect(
|
||||
bookingCloseCutoff(departure, 'IMPORT', {
|
||||
importCloseOffsetMinutes: -5,
|
||||
}).toISOString(),
|
||||
).toBe(departure.toISOString());
|
||||
});
|
||||
|
||||
it('import 3-hour offset: 16:00 EAT departure → cutoff 13:00 EAT (14:00 → 3h before)', () => {
|
||||
// Departure 16:00 EAT (13:00 UTC), 3h offset → 13:00 EAT = 10:00 UTC.
|
||||
const cutoff = bookingCloseCutoff(departure, 'IMPORT', {
|
||||
importCloseOffsetMinutes: 180,
|
||||
});
|
||||
expect(cutoff.toISOString()).toBe('2026-07-10T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('export 1-day offset: Jul-10 16:00 EAT departure → cutoff Jul-9 16:00 EAT', () => {
|
||||
const cutoff = bookingCloseCutoff(departure, 'EXPORT', {
|
||||
exportCloseOffsetMinutes: 1440,
|
||||
});
|
||||
// Jul 9 16:00 EAT = Jul 9 13:00 UTC.
|
||||
expect(cutoff.toISOString()).toBe('2026-07-09T13:00:00.000Z');
|
||||
});
|
||||
|
||||
it('import and export offsets are independent', () => {
|
||||
const cfg = {
|
||||
importCloseOffsetMinutes: 180,
|
||||
exportCloseOffsetMinutes: 1440,
|
||||
};
|
||||
expect(bookingCloseCutoff(departure, 'IMPORT', cfg).toISOString()).toBe(
|
||||
'2026-07-10T10:00:00.000Z',
|
||||
);
|
||||
expect(bookingCloseCutoff(departure, 'EXPORT', cfg).toISOString()).toBe(
|
||||
'2026-07-09T13:00:00.000Z',
|
||||
);
|
||||
// DOMESTIC uses the import offset.
|
||||
expect(bookingCloseCutoff(departure, 'DOMESTIC', cfg).toISOString()).toBe(
|
||||
'2026-07-10T10:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('window-time computation honours the close offset', () => {
|
||||
it('export closes at departure − offset, not departure', () => {
|
||||
// Departs Jul 10 16:00 EAT (13:00 UTC), lead 24h, 24-hour desk, 1-day offset.
|
||||
const departure = new Date('2026-07-10T13:00:00.000Z');
|
||||
const { windowClosesAt } = computeExportWindowTimes(departure, {
|
||||
exportBookingLeadHours: 48,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 8, // 24-hour desk
|
||||
exportCloseOffsetMinutes: 1440,
|
||||
});
|
||||
// Jul 9 16:00 EAT = Jul 9 13:00 UTC.
|
||||
expect(windowClosesAt.toISOString()).toBe('2026-07-09T13:00:00.000Z');
|
||||
});
|
||||
|
||||
it('import close is capped at the cutoff (departure − offset)', () => {
|
||||
// Round-the-clock desk, opens 05 Jul 12:00 EAT, 24h duration would run to
|
||||
// 06 Jul 12:00; departure 06 Jul 08:00 EAT (05:00 UTC) with a 2-hour offset →
|
||||
// cutoff 06 Jul 06:00 EAT = 03:00 UTC.
|
||||
const departure = new Date('2026-07-06T05:00:00.000Z');
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(
|
||||
departure,
|
||||
{
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 8, // 24-hour desk (no office-hour cap)
|
||||
windowDurationHours: 24,
|
||||
importCloseOffsetMinutes: 120,
|
||||
},
|
||||
now,
|
||||
);
|
||||
expect(windowClosesAt.toISOString()).toBe('2026-07-06T03:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,6 +266,29 @@ export function clampCloseToOfficeHours(
|
||||
return closesAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a schedule stops accepting bookings. By default that is departure,
|
||||
* but a configured close offset (import/export, minutes) pulls it earlier:
|
||||
* `departure − offset`. This is the single bound every window close, reopen
|
||||
* cycle and export FCFS close is capped at — swap it in wherever the logic used
|
||||
* to cap at departure. A non-positive/absent offset yields departure unchanged.
|
||||
*/
|
||||
export function bookingCloseCutoff(
|
||||
departure: Date,
|
||||
direction: string | null | undefined,
|
||||
cfg: {
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
},
|
||||
): Date {
|
||||
const offsetMinutes =
|
||||
direction === 'EXPORT'
|
||||
? cfg.exportCloseOffsetMinutes
|
||||
: cfg.importCloseOffsetMinutes;
|
||||
if (offsetMinutes == null || !(offsetMinutes > 0)) return departure;
|
||||
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||
}
|
||||
|
||||
export interface InitialWindowTimes {
|
||||
windowOpensAt: Date;
|
||||
windowClosesAt: Date;
|
||||
@@ -296,9 +319,13 @@ export function computeImportWindowTimes(
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number;
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
},
|
||||
now: Date,
|
||||
): InitialWindowTimes {
|
||||
// The window opens off the REAL departure (open day = departure − leadDays),
|
||||
// but shuts at the configured cutoff (departure − closeOffset, or departure).
|
||||
const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg);
|
||||
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||
const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||
|
||||
@@ -324,8 +351,8 @@ export function computeImportWindowTimes(
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
});
|
||||
if (closesAt.getTime() > departure.getTime()) {
|
||||
closesAt = departure;
|
||||
if (closesAt.getTime() > cutoff.getTime()) {
|
||||
closesAt = cutoff;
|
||||
}
|
||||
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
||||
}
|
||||
@@ -344,8 +371,12 @@ export function computeExportWindowTimes(
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
},
|
||||
): InitialWindowTimes {
|
||||
// Opens off the real departure (lead hours), shuts at the cutoff
|
||||
// (departure − closeOffset, or departure when no offset is set).
|
||||
const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg);
|
||||
const rawOpen = new Date(
|
||||
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
|
||||
);
|
||||
@@ -353,10 +384,12 @@ export function computeExportWindowTimes(
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
});
|
||||
if (opensAt.getTime() > departure.getTime()) {
|
||||
opensAt = departure;
|
||||
// Open can't outlive the cutoff (a huge offset would otherwise leave a
|
||||
// negative-length window); clamp to a zero-length window at the cutoff.
|
||||
if (opensAt.getTime() > cutoff.getTime()) {
|
||||
opensAt = cutoff;
|
||||
}
|
||||
return { windowOpensAt: opensAt, windowClosesAt: departure };
|
||||
return { windowOpensAt: opensAt, windowClosesAt: cutoff };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,6 +490,10 @@ export interface BoardWindowConfig {
|
||||
*/
|
||||
reopenGapMinutes: number;
|
||||
exportBookingLeadHours: number;
|
||||
/** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
/** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -507,10 +544,15 @@ export function listConfigBookingWindows(
|
||||
cfg: BoardWindowConfig,
|
||||
anchorOpensAt?: Date | null,
|
||||
): BoardWindow[] {
|
||||
// Bookings shut at the cutoff (departure − closeOffset), not departure. The
|
||||
// window opens still key off the real departure below; only closes are capped
|
||||
// here, so the board draws the exact windows the engine runs.
|
||||
const cutoff = bookingCloseCutoff(departure, direction, cfg);
|
||||
|
||||
if (direction === 'EXPORT') {
|
||||
const start =
|
||||
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
|
||||
return [boardWindowFromInterval(start, departure)];
|
||||
return [boardWindowFromInterval(start, cutoff)];
|
||||
}
|
||||
|
||||
const windows: BoardWindow[] = [];
|
||||
@@ -527,29 +569,29 @@ export function listConfigBookingWindows(
|
||||
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||
// The loop terminates naturally: every cycle advances opensAt by at least
|
||||
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
|
||||
// reach departure. maxCycles is a derived runaway backstop sized to the real
|
||||
// span (first open → departure) over the smallest possible advance, so a
|
||||
// reach the cutoff. maxCycles is a derived runaway backstop sized to the real
|
||||
// span (first open → cutoff) over the smallest possible advance, so a
|
||||
// legitimate config is never silently truncated — only a pathological
|
||||
// zero-length one would hit it.
|
||||
const spanMs = departure.getTime() - opensAt.getTime();
|
||||
const spanMs = cutoff.getTime() - opensAt.getTime();
|
||||
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
|
||||
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
|
||||
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
|
||||
if (opensAt.getTime() >= departure.getTime()) break;
|
||||
if (opensAt.getTime() >= cutoff.getTime()) break;
|
||||
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
|
||||
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
|
||||
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||
|
||||
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
|
||||
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
|
||||
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff);
|
||||
if (opensAt == null) break;
|
||||
}
|
||||
|
||||
// Degenerate config (no window before departure) — surface a single window
|
||||
// clamped to departure so the board still renders something meaningful.
|
||||
// Degenerate config (no window before the cutoff) — surface a single window
|
||||
// clamped to the cutoff so the board still renders something meaningful.
|
||||
if (windows.length === 0) {
|
||||
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
|
||||
windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff));
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
@@ -137,4 +137,109 @@ describe('BookingBatchService — exportSpaceReport (whole-booking, single train
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
});
|
||||
|
||||
describe('dayImportAvailability (advisory, summed across the day)', () => {
|
||||
const DAY_STR = '2026-07-20';
|
||||
|
||||
const importSchedule = (id: string, over: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
status: 'SCHEDULED',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: DAY,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase
|
||||
...over,
|
||||
});
|
||||
|
||||
// A bulk booking small enough to fit; freeWagons is what matters, not `fits`.
|
||||
const importBooking = (cargoTons: number) =>
|
||||
({
|
||||
id: 'bk-imp',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
cargoTotalWeightVgm: cargoTons,
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
it('sums free wagons across every import train on the day', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1'),
|
||||
importSchedule('train-2'),
|
||||
]);
|
||||
|
||||
const one = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
// Re-run with a single train to prove two trains sum to double one train.
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1'),
|
||||
]);
|
||||
const solo = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
|
||||
expect(solo.freeWagons).toBeGreaterThan(0);
|
||||
expect(one.freeWagons).toBe(solo.freeWagons * 2);
|
||||
expect(one.trainsForDay).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores EXPORT trains — they are not part of the import pool', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1'),
|
||||
{ ...importSchedule('train-2'), direction: 'EXPORT' },
|
||||
]);
|
||||
|
||||
const both = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1'),
|
||||
]);
|
||||
const solo = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
|
||||
expect(both.freeWagons).toBe(solo.freeWagons);
|
||||
});
|
||||
|
||||
it('ignores FULL trains', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1', { bookingWindowStatus: 'FULL' }),
|
||||
]);
|
||||
|
||||
const report = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
|
||||
expect(report.freeWagons).toBe(0);
|
||||
expect(report.trainsForDay).toBe(false);
|
||||
});
|
||||
|
||||
it('nets out capacity already held by reserved bookings', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
importSchedule('train-1'),
|
||||
]);
|
||||
const empty = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
|
||||
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||
heavyReserved,
|
||||
]);
|
||||
const withHold = await service.dayImportAvailability(
|
||||
importBooking(60),
|
||||
DAY_STR,
|
||||
);
|
||||
|
||||
expect(withHold.freeWagons).toBeLessThan(empty.freeWagons);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -731,6 +731,64 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||||
* summed across every train on the booking's corridor that day. Unlike the
|
||||
* export gate this does NOT block and does NOT first-fit a single train:
|
||||
* import is batched and splittable, so the honest number a customer can plan
|
||||
* against is the TOTAL room across the day's trains for the booking's wagon
|
||||
* type, in that type's own wagon units.
|
||||
*
|
||||
* It deliberately skips the `isFillable` window-phase gate. A customer picks a
|
||||
* shipment day while its window is still OPEN (or pre-window) — the batch fill
|
||||
* only makes those trains fillable after the window closes — so gating on the
|
||||
* fill phase here would report 0 for exactly the days customers are choosing.
|
||||
* We therefore count any non-FULL train that carries the leg, netting out the
|
||||
* capacity already consumed by allocated + live-reserved bookings
|
||||
* (`remainingBudget`). The count is an upper bound: the batch engine may still
|
||||
* split the booking across trains or defer a remainder to a later window.
|
||||
*/
|
||||
async dayImportAvailability(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.bookingWindowStatus !== 'FULL' &&
|
||||
s.direction !== 'EXPORT',
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const dims = this.dimsFor(booking, wagonDims);
|
||||
const need = this.wagonsFor(booking, wagonDims);
|
||||
let freeWagons = 0;
|
||||
let trainsForDay = false;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
trainsForDay = true;
|
||||
freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons;
|
||||
}
|
||||
|
||||
return { freeWagons, need, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||
@@ -1118,6 +1176,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s.ruleExportBookingLeadHours,
|
||||
liveCfg.exportBookingLeadHours,
|
||||
),
|
||||
// Frozen close offsets: a snapshot null means "no offset for this train"
|
||||
// and stays null (not the live offset); only legacy rows lacking the
|
||||
// column (undefined) fall back to live config.
|
||||
importCloseOffsetMinutes:
|
||||
s.ruleImportCloseOffsetMinutes !== undefined
|
||||
? s.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
s.ruleExportCloseOffsetMinutes !== undefined
|
||||
? s.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
|
||||
@@ -19,6 +19,17 @@ export interface BookingWindowConfig {
|
||||
/** Max staff document-review time after the window closes. */
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
/**
|
||||
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set
|
||||
* (> 0), the effective booking cutoff is `departure − this`, capping the first
|
||||
* window close and every reopen cycle. NULL/0 ⇒ no offset (close at departure).
|
||||
*/
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
/**
|
||||
* Minutes before departure the EXPORT FCFS booking window shuts. When set (> 0),
|
||||
* export closes at `departure − this` instead of at departure. NULL/0 ⇒ none.
|
||||
*/
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
eatDay,
|
||||
nextCycleOpensAt,
|
||||
@@ -415,6 +416,15 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// window and the cycle stays in PAYMENT; check live reservations on THIS
|
||||
// schedule because the day-level fill may have reserved onto a sibling.
|
||||
// Waiting bookings that fit no train stay pooled and the window reopens.
|
||||
// Booking shuts at the configured cutoff (departure − closeOffset), not
|
||||
// departure — every phase-end below is bounded by it, mirroring the initial
|
||||
// window computation.
|
||||
const cutoff = bookingCloseCutoff(
|
||||
schedule.scheduledDepartureDate,
|
||||
schedule.direction,
|
||||
cfg,
|
||||
);
|
||||
|
||||
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
|
||||
if (
|
||||
promoted > 0 &&
|
||||
@@ -423,8 +433,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
let paymentPhaseEndsAt = new Date(
|
||||
now.getTime() + cfg.paymentWindowMinutes * 60_000,
|
||||
);
|
||||
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
|
||||
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
|
||||
if (paymentPhaseEndsAt > cutoff) {
|
||||
paymentPhaseEndsAt = cutoff;
|
||||
}
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
this.logger.log(
|
||||
@@ -441,11 +451,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
};
|
||||
const nextOpensAt = nextCycleOpensAt(
|
||||
now,
|
||||
officeHours,
|
||||
schedule.scheduledDepartureDate,
|
||||
);
|
||||
const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff);
|
||||
if (nextOpensAt == null) {
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
this.logger.log(
|
||||
@@ -464,8 +470,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// Office hours end a running window early: never let the duration outlive
|
||||
// the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
|
||||
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
|
||||
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||
nextClosesAt = schedule.scheduledDepartureDate;
|
||||
if (nextClosesAt > cutoff) {
|
||||
nextClosesAt = cutoff;
|
||||
}
|
||||
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
|
||||
// whether that is later today or next morning after the office-hours break.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -61,4 +62,15 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Reverse the wagon order on this train: the physically-last wagon becomes ' +
|
||||
'position 1. Frozen on the schedule; applied every time the wagon plan is ' +
|
||||
'rebuilt so the stored train order and the schedule order stay in sync.',
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -58,4 +59,15 @@ export class PreviewTrainScheduleDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Reverse the wagon order on the train: the physically-last wagon becomes ' +
|
||||
'position 1. The composition and allocations are unchanged — only the order ' +
|
||||
'flips, applied at build so the stored train and schedule stay in sync.',
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,4 +67,31 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
paymentWindowMinutes?: number;
|
||||
|
||||
// Booking-close offsets: minutes before departure the window shuts. The UI
|
||||
// enters days/hours/minutes and converts to minutes. 0 or null clears the
|
||||
// offset (close at departure). Nullable so it can be explicitly cleared.
|
||||
@ApiPropertyOptional({
|
||||
example: 180,
|
||||
nullable: true,
|
||||
description:
|
||||
'Minutes before departure the IMPORT booking window closes; 0/null = close at departure',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1440,
|
||||
nullable: true,
|
||||
description:
|
||||
'Minutes before departure the EXPORT booking window closes; 0/null = close at departure',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
@@ -79,4 +79,21 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||
|
||||
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
|
||||
paymentWindowMinutes!: number;
|
||||
|
||||
/**
|
||||
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set,
|
||||
* the window's close (first cycle and every reopen) is capped at
|
||||
* `departure − this`, instead of the default open+duration/departure cap.
|
||||
* NULL or 0 = no offset (previous behaviour).
|
||||
*/
|
||||
@Column({ name: 'import_close_offset_minutes', type: 'int', nullable: true })
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
|
||||
/**
|
||||
* Minutes before departure the EXPORT FCFS booking window shuts. When set, the
|
||||
* export window closes at `departure − this` instead of at departure. NULL or
|
||||
* 0 = no offset (export closes at departure, previous behaviour).
|
||||
*/
|
||||
@Column({ name: 'export_close_offset_minutes', type: 'int', nullable: true })
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,68 @@ export class IntercityService {
|
||||
* remaining capacity along all three axes (wagons, weight, length) and each
|
||||
* booking's need, so staff can pick what fits.
|
||||
*/
|
||||
/**
|
||||
* Every intercity booking and where it is in its ride-along, across all trains.
|
||||
*
|
||||
* The per-schedule candidate list answers "what can THIS train carry"; this
|
||||
* answers "what is happening to intercity cargo" — which is what a yard
|
||||
* operator needs when the work is spread over whichever trains happen to pass.
|
||||
*
|
||||
* Carries each end's facility state, because a booking whose origin or
|
||||
* destination has no facility can never be loaded or unloaded there and the
|
||||
* operator should see that before the train arrives, not when the load is
|
||||
* refused.
|
||||
*/
|
||||
async listBookings() {
|
||||
return this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "reference",
|
||||
b.status AS "status",
|
||||
b.freight_type AS "freightType",
|
||||
b.cargo_total_weight_vgm AS "weightTons",
|
||||
b.loaded_at AS "loadedAt",
|
||||
b.arrived_at AS "arrivedAt",
|
||||
company.name AS "customer",
|
||||
b.train_schedule_id AS "trainScheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
ts.status AS "scheduleStatus",
|
||||
oy.id AS "originYardId",
|
||||
COALESCE(oy.label, oy.code) AS "origin",
|
||||
oy.has_facility AS "originHasFacility",
|
||||
dy.id AS "destinationYardId",
|
||||
COALESCE(dy.label, dy.code) AS "destination",
|
||||
dy.has_facility AS "destinationHasFacility",
|
||||
-- Where the train actually is, so the operator knows if the cargo
|
||||
-- can be worked right now.
|
||||
cp.yard_id AS "trainAtYardId",
|
||||
-- Most recent GRN raised for this booking at a facility.
|
||||
fh.grn_number AS "grnNumber"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedules ts
|
||||
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT c.yard_id
|
||||
FROM freight.train_checkpoint_events c
|
||||
WHERE c.train_schedule_id = b.train_schedule_id
|
||||
ORDER BY c.occurred_at DESC, c.created_at DESC
|
||||
LIMIT 1
|
||||
) cp ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT e.grn_number
|
||||
FROM freight.facility_handling_events e
|
||||
WHERE e.booking_id = b.id AND e.deleted_at IS NULL
|
||||
ORDER BY e.occurred_at DESC
|
||||
LIMIT 1
|
||||
) fh ON true
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.trade_direction = 'DOMESTIC'
|
||||
ORDER BY b.created_at DESC`,
|
||||
);
|
||||
}
|
||||
|
||||
async listCandidates(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
|
||||
@@ -459,6 +459,16 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Every intercity booking with its ride-along state, both yards' facility status, and where its train is",
|
||||
})
|
||||
listIntercityBookings() {
|
||||
return this.intercityService.listBookings();
|
||||
}
|
||||
|
||||
@Get("schedules/:id/intercity-candidates")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -234,13 +234,17 @@ describe('TrainSchedulingService', () => {
|
||||
destinationStationId: 'yard-destination',
|
||||
});
|
||||
|
||||
// Availability rows now report what the BOUNDED plan actually uses per
|
||||
// type (never more than stock, so no shortfall on the rows themselves);
|
||||
// the shortage is carried by the deferred bookings' own shortage rows.
|
||||
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
|
||||
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.fleetAvailability?.every((row) => row.needed <= row.available),
|
||||
).toBe(true);
|
||||
expect(result.deferredBookings?.length).toBeGreaterThan(0);
|
||||
expect(result.deferredBookings?.[0]?.reason).toContain('short');
|
||||
expect(result.summary.wagonsNeeded).toBeLessThan(30);
|
||||
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true);
|
||||
});
|
||||
|
||||
it('computes slot-based preview for Group A', async () => {
|
||||
|
||||
@@ -109,12 +109,13 @@ import {
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
import {
|
||||
applyWagonOrderReversal,
|
||||
planWagonsWithStock,
|
||||
unboundedStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from './wagon-plan-flex.util';
|
||||
import {
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
getContainerSlotSequenceNos,
|
||||
roundTons,
|
||||
@@ -134,8 +135,10 @@ import {
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
} from './booking-batch.constants';
|
||||
@@ -180,6 +183,8 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
|
||||
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||||
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
||||
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
||||
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
|
||||
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +209,8 @@ export function effectiveWindowConfig(
|
||||
ruleReopenDelayMinutes?: number | null;
|
||||
ruleImportWindowLeadDays?: number | null;
|
||||
ruleExportBookingLeadHours?: number | null;
|
||||
ruleImportCloseOffsetMinutes?: number | null;
|
||||
ruleExportCloseOffsetMinutes?: number | null;
|
||||
},
|
||||
liveCfg: BookingWindowConfig,
|
||||
): BookingWindowConfig {
|
||||
@@ -220,6 +227,18 @@ export function effectiveWindowConfig(
|
||||
: liveCfg.windowDurationHours,
|
||||
docReviewMinutes: liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
|
||||
// The close offset is frozen per-schedule: a snapshot value of null means
|
||||
// "created with no offset" and must NOT inherit a later live offset (that
|
||||
// would retro-shrink an open train's window). Only a truly legacy row that
|
||||
// predates the snapshot column (value undefined) falls back to live config.
|
||||
importCloseOffsetMinutes:
|
||||
schedule.ruleImportCloseOffsetMinutes !== undefined
|
||||
? schedule.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
schedule.ruleExportCloseOffsetMinutes !== undefined
|
||||
? schedule.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -588,7 +607,11 @@ export class TrainSchedulingService {
|
||||
trainScheduleId: query.trainScheduleId,
|
||||
day,
|
||||
});
|
||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
return {
|
||||
count: bookings.length,
|
||||
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
|
||||
};
|
||||
}
|
||||
|
||||
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||||
@@ -633,6 +656,11 @@ export class TrainSchedulingService {
|
||||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||
// Store 0 as null so "no offset" is a single canonical value.
|
||||
if (dto.importCloseOffsetMinutes !== undefined)
|
||||
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
|
||||
if (dto.exportCloseOffsetMinutes !== undefined)
|
||||
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
|
||||
|
||||
// The booking desk supports three shapes: a same-day range
|
||||
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
|
||||
@@ -649,7 +677,9 @@ export class TrainSchedulingService {
|
||||
dto.windowDurationHours != null ||
|
||||
dto.docReviewMinutes != null ||
|
||||
dto.paymentWindowMinutes != null ||
|
||||
dto.exportBookingLeadHours != null;
|
||||
dto.exportBookingLeadHours != null ||
|
||||
dto.importCloseOffsetMinutes !== undefined ||
|
||||
dto.exportCloseOffsetMinutes !== undefined;
|
||||
|
||||
const saved = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
@@ -721,6 +751,17 @@ export class TrainSchedulingService {
|
||||
// override changes them, so the derived snapshot delay stays consistent.
|
||||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
|
||||
// A per-schedule override isn't a close-offset control, so inherit the
|
||||
// offset already frozen on the schedule (null = none), or the live one for
|
||||
// legacy rows — the override must not silently drop the global offset.
|
||||
importCloseOffsetMinutes:
|
||||
schedule.ruleImportCloseOffsetMinutes !== undefined
|
||||
? schedule.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
schedule.ruleExportCloseOffsetMinutes !== undefined
|
||||
? schedule.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
|
||||
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
|
||||
@@ -1054,6 +1095,13 @@ export class TrainSchedulingService {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
// Offsets are optional: a missing/unset value means "no offset", not a
|
||||
// numeric default — keep it null so bookingCloseCutoff falls back to
|
||||
// departure. Zero and negatives are treated as "no offset" too.
|
||||
const offset = (v: unknown): number | null => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
return {
|
||||
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||||
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||||
@@ -1062,6 +1110,8 @@ export class TrainSchedulingService {
|
||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
|
||||
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1322,6 +1372,7 @@ export class TrainSchedulingService {
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
...windowFields,
|
||||
}),
|
||||
);
|
||||
@@ -1400,6 +1451,10 @@ export class TrainSchedulingService {
|
||||
maxTrainWeightTons: dto.maxTrainWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
|
||||
// created — every (re)assignment rebuilds the plan under the same flag so the
|
||||
// stored train order stays consistent no matter how bookings are added.
|
||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||
};
|
||||
|
||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||
@@ -1476,6 +1531,31 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
// Every REQUESTED booking must have made the plan. Silently dropping a
|
||||
// deferred one let the workspace "Add from pool" report success while the
|
||||
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
|
||||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||||
// override it either.
|
||||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
|
||||
if (droppedRequested.length) {
|
||||
const reasonById = new Map(
|
||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||
);
|
||||
const details = droppedRequested.map(
|
||||
(id) =>
|
||||
reasonById.get(id) ??
|
||||
`${id}: does not fit the train's wagon stock or capacity`,
|
||||
);
|
||||
throw new BadRequestException({
|
||||
message: `Cannot allocate — ${details.join('; ')}`,
|
||||
violations: details,
|
||||
warnings: validation.warnings,
|
||||
deferredBookings: validation.deferredBookings,
|
||||
});
|
||||
}
|
||||
|
||||
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
|
||||
const totalWeightTons = validation.summary.totalWeightTons;
|
||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||
@@ -1817,13 +1897,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
const items = bookings
|
||||
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? null,
|
||||
customer: b.company?.name ?? null,
|
||||
weightTons: b.cargoTotalWeightVgm,
|
||||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||||
weightTons: this.grossBookingWeightTons(b, tareDims),
|
||||
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
|
||||
}));
|
||||
return { count: items.length, items };
|
||||
@@ -3668,13 +3750,6 @@ export class TrainSchedulingService {
|
||||
const allowed = await this.loadAllowedWagonTypes(bookings);
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
|
||||
|
||||
// Pure demand (unbounded stock) drives the availability report rows.
|
||||
const demandPlan = planWagonsWithStock({
|
||||
bookings,
|
||||
allowed,
|
||||
stock: unboundedStock(allowed),
|
||||
}).plan;
|
||||
|
||||
const originYardId = dto.originStationId;
|
||||
let stock: WagonStock;
|
||||
if (builtTrainId) {
|
||||
@@ -3711,10 +3786,24 @@ export class TrainSchedulingService {
|
||||
violations.push(...planned.configIssues);
|
||||
const fittingBookings = planned.fitting;
|
||||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||||
const wagonPlan = planned.plan;
|
||||
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
|
||||
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
|
||||
// the stored train order, allocations and snapshot all carry the reversed
|
||||
// order together. No-op unless the schedule set the flag.
|
||||
const wagonPlan = applyWagonOrderReversal(
|
||||
planned.plan,
|
||||
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
|
||||
);
|
||||
|
||||
// Availability rows come from the BOUNDED plan — the one that actually
|
||||
// mixes wagon types against real stock. The old unbounded "pure demand"
|
||||
// plan had infinite stock of every allowed type, so its tie-break parked a
|
||||
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
|
||||
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
|
||||
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
|
||||
// the deferred bookings' own shortage rows.
|
||||
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
|
||||
demandPlan,
|
||||
planned.plan,
|
||||
stock.remainingByTypeId,
|
||||
stock.codesByTypeId,
|
||||
);
|
||||
@@ -4805,7 +4894,10 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
private mapEligibleBooking(booking: Booking) {
|
||||
private mapEligibleBooking(
|
||||
booking: Booking,
|
||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||
) {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
@@ -4819,7 +4911,8 @@ export class TrainSchedulingService {
|
||||
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
|
||||
quantity:
|
||||
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
|
||||
weightTons: roundTons(booking.cargoTotalWeightVgm),
|
||||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||||
weightTons: this.grossBookingWeightTons(booking, tareDims),
|
||||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||
destination:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||
@@ -6110,6 +6203,85 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
|
||||
* engine's representative fallbacks for bookings whose cargo/container type
|
||||
* has no wagon type configured. Loaded once per request before mapping.
|
||||
*/
|
||||
private async loadWagonTareDims(): Promise<{
|
||||
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
|
||||
bulk: { tareWeightTons: number; capacityTons: number };
|
||||
container: { tareWeightTons: number; capacityTons: number };
|
||||
}> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byWagonTypeId = new Map(
|
||||
types.map((t) => [
|
||||
t.id,
|
||||
{
|
||||
tareWeightTons: Number(t.tareWeightTons) || 0,
|
||||
capacityTons: Number(t.capacityTons) || 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
byWagonTypeId,
|
||||
bulk: {
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||||
},
|
||||
container: {
|
||||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
|
||||
* every wagon the booking occupies — the same gross axis the batch engine
|
||||
* spends against the locomotive's pull limit. Wagon count mirrors the batch
|
||||
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
|
||||
* tons ÷ payload for bulk — whichever is largest).
|
||||
*/
|
||||
private grossBookingWeightTons(
|
||||
booking: Pick<
|
||||
Booking,
|
||||
| 'freightType'
|
||||
| 'cargoTotalWeightVgm'
|
||||
| 'wagonsRequired'
|
||||
| 'bookingContainers'
|
||||
| 'cargoType'
|
||||
>,
|
||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||
): number {
|
||||
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const fallback =
|
||||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
||||
const wagonTypeId =
|
||||
booking.freightType === 'BULK'
|
||||
? booking.cargoType?.wagonTypes?.[0]?.id
|
||||
: (booking.bookingContainers ?? [])
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wagonType) => wagonType.id)
|
||||
.find((id): id is string => Boolean(id));
|
||||
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
|
||||
const dims = {
|
||||
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
|
||||
capacityTons: typed?.capacityTons || fallback.capacityTons,
|
||||
};
|
||||
|
||||
const stored =
|
||||
booking.wagonsRequired && booking.wagonsRequired > 0
|
||||
? Math.ceil(booking.wagonsRequired)
|
||||
: 0;
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
const byWeight =
|
||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||
const wagons = Math.max(1, stored, byLength, byWeight);
|
||||
return roundTons(cargo + wagons * dims.tareWeightTons);
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
@@ -6118,6 +6290,9 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const allocationIds = allocations.map((a) => a.id);
|
||||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||||
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
|
||||
// locomotive actually hauls and the axis its pull limit is compared against.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
|
||||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||||
@@ -6406,7 +6581,9 @@ export class TrainSchedulingService {
|
||||
id: sb.booking?.id ?? sb.bookingId,
|
||||
reference: sb.booking?.reference ?? null,
|
||||
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
|
||||
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
|
||||
weightTons: sb.booking
|
||||
? this.grossBookingWeightTons(sb.booking, tareDims)
|
||||
: 0,
|
||||
status: sb.booking?.status ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { planWagonsWithStock } from './wagon-plan-flex.util';
|
||||
import {
|
||||
applyWagonOrderReversal,
|
||||
planWagonsWithStock,
|
||||
} from './wagon-plan-flex.util';
|
||||
import type { WagonPlanSlot } from './wagon-plan.util';
|
||||
|
||||
const nw6: WagonType = {
|
||||
id: 'wt-nw6',
|
||||
@@ -130,3 +134,56 @@ describe('planWagonsWithStock — shortage detail', () => {
|
||||
expect(result.deferred[0]?.shortage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyWagonOrderReversal', () => {
|
||||
const slot = (
|
||||
seq: number,
|
||||
wagonTypeId: string,
|
||||
bookingId: string,
|
||||
): WagonPlanSlot =>
|
||||
({
|
||||
sequenceNo: seq,
|
||||
wagonTypeId,
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: 25,
|
||||
allocations: [{ bookingId }],
|
||||
}) as unknown as WagonPlanSlot;
|
||||
|
||||
const plan: WagonPlanSlot[] = [
|
||||
slot(1, 'wt-a', 'BKG-A'),
|
||||
slot(2, 'wt-b', 'BKG-B'),
|
||||
slot(3, 'wt-c', 'BKG-C'),
|
||||
];
|
||||
|
||||
it('returns the plan unchanged when the flag is false/absent', () => {
|
||||
expect(applyWagonOrderReversal(plan, false)).toBe(plan);
|
||||
expect(applyWagonOrderReversal(plan, undefined)).toBe(plan);
|
||||
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
|
||||
});
|
||||
|
||||
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// Physically-last wagon (was seq 3, wt-c) is now position 1.
|
||||
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
|
||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('keeps each booking with its own wagon — only the position changes', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// The booking that was in the last wagon now sits at sequenceNo 1.
|
||||
expect(reversed[0].sequenceNo).toBe(1);
|
||||
expect(
|
||||
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-C');
|
||||
expect(
|
||||
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-A');
|
||||
});
|
||||
|
||||
it('does not mutate the input plan', () => {
|
||||
applyWagonOrderReversal(plan, true);
|
||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +340,31 @@ export function planWagonsWithStock(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the wagon ORDER of a built plan when a schedule opts in.
|
||||
*
|
||||
* The plan comes out of planWagonsWithStock ordered by booking scheduling order
|
||||
* (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last
|
||||
* wagon becomes wagon #1: the slot objects — and the bookings already allocated
|
||||
* into each — travel WITH their slot, so only the position numbers flip. The
|
||||
* physical composition, which booking is in which wagon, and every per-slot
|
||||
* field are untouched; sequenceNo is renumbered 1..N over the reversed array.
|
||||
*
|
||||
* This single flip is the whole feature: persistTrainSetWagons writes these
|
||||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
||||
* read them — so the stored train order and the schedule order stay identical,
|
||||
* just reversed. A false/absent flag returns the plan unchanged.
|
||||
*/
|
||||
export function applyWagonOrderReversal(
|
||||
plan: WagonPlanSlot[],
|
||||
reverse: boolean | null | undefined,
|
||||
): WagonPlanSlot[] {
|
||||
if (!reverse) return plan;
|
||||
return [...plan]
|
||||
.reverse()
|
||||
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
|
||||
@@ -29,6 +29,13 @@ export class ListWagonsQueryDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trainNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'wagonNumber' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
@@ -44,22 +44,41 @@ export class WagonsService {
|
||||
}
|
||||
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||
const search = query.search?.trim();
|
||||
const trainId = query.trainId?.trim();
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
const trainNumber = query.trainNumber?.trim();
|
||||
|
||||
// QueryBuilder (not find) because both search and the trainNumber filter span
|
||||
// two columns each (export/import run) — an OR that FindOptions cannot express
|
||||
// without cross-producting into conflicting branches. Soft-deleted rows are
|
||||
// still excluded automatically (BaseEntity's @DeleteDateColumn).
|
||||
const qb = this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
.leftJoinAndSelect('w.currentYard', 'currentYard')
|
||||
.leftJoinAndSelect('w.wagonType', 'wagonType');
|
||||
|
||||
if (query.status) qb.andWhere('w.status = :status', { status: query.status });
|
||||
if (query.currentYardId)
|
||||
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
|
||||
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
|
||||
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
|
||||
|
||||
// Filter by run: the odd export run identifies the pair, so match either
|
||||
// column — a wagon carries export on one, import on the other.
|
||||
if (trainNumber) {
|
||||
qb.andWhere(
|
||||
'(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)',
|
||||
{ trainNumber },
|
||||
);
|
||||
}
|
||||
|
||||
// Search matches the wagon number or either run number.
|
||||
if (search) {
|
||||
where.push({
|
||||
wagonNumber: ILike(`%${search}%`),
|
||||
...filters,
|
||||
});
|
||||
qb.andWhere(
|
||||
'(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)',
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
// Spec columns (tare, payload) are no longer sortable here — they live on the
|
||||
@@ -75,14 +94,14 @@ export class WagonsService {
|
||||
? (query.sortBy as keyof Wagon)
|
||||
: 'wagonNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
qb.orderBy(`w.${sortBy}`, sortOrder);
|
||||
|
||||
return this.wagonRepo.find({
|
||||
where: search ? where : filters,
|
||||
relations: { currentYard: true, wagonType: true },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
if (query.page && query.limit) {
|
||||
qb.skip((Number(query.page) - 1) * Number(query.limit));
|
||||
}
|
||||
if (query.limit) qb.take(Number(query.limit));
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
|
||||
Reference in New Issue
Block a user