feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
committed by Hagernesh
parent b17a06052b
commit a9c6b85c08
32 changed files with 1195 additions and 660 deletions

View File

@@ -0,0 +1,33 @@
import { adHocLabel } from './clearance.util';
/**
* The customer's typed document name travels to the API inside the multipart
* field code (`custom_<slug>_<n>`) — the only channel a part has — and comes
* back out here for GL's review grid. Mirror of `adHocSlug` in the portal's
* useClearanceFlow.
*/
const adHocSlug = (name: string) =>
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`);
describe('adHocLabel', () => {
it('recovers the name the customer typed', () => {
expect(roundTrip('Special permit')).toBe('Special permit');
expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate');
expect(roundTrip('bank slip #2')).toBe('Bank slip 2');
});
it('returns null when there is no name to show, so callers use the filename', () => {
expect(roundTrip('')).toBeNull();
// Legacy uploads keyed `custom_<timestamp>_<n>` carry no name — without the
// digits guard this would surface "1755780000000" as the document label.
expect(adHocLabel('custom_1755780000000_0')).toBeNull();
expect(adHocLabel('commercial_invoice')).toBeNull();
});
});

View File

@@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => {
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
@@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
: [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const containerBooking = (overrides: Record<string, unknown> = {}) =>
@@ -396,11 +398,14 @@ describe('BookingPricingService — customs clearance fee billed on the booking
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
rateValue: 40,
} as Rate;
// No serviceType relation on the booking (like the GL/portal shipment
// preview) — the flag must be resolved from serviceTypeId.
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
};
const result = await service.computePriceForBooking(
containerBooking({
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
} as never),
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
);
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
@@ -574,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
@@ -704,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
const booking = (

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
@@ -84,6 +85,7 @@ export class BookingPricingService {
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
private readonly cargoTypesService: CargoTypesService,
private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -1061,8 +1063,17 @@ export class BookingPricingService {
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
// An Ethiopian-side-only customs service prices off its own rate; the
// contract froze its snapshots under the matching code prefix.
const customsType = booking.serviceType?.includesEthiopianCustomsOnly
// contract froze its snapshots under the matching code prefix. Resolved by
// id when the relation isn't loaded — the GL / portal shipment previews
// price a transient booking object, and a missing relation must not
// silently quote the standard fee the created booking is then billed
// differently for.
const serviceType =
booking.serviceType ??
(booking.serviceTypeId
? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null)
: null);
const customsType = serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =

View File

@@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import {
adHocLabel,
clearanceCodesForBooking,
clearanceDocumentsOpen,
} from './clearance.util';
@@ -742,7 +743,9 @@ export class BookingTransitionService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: "customer",
settingCode: "custom",
@@ -890,6 +893,10 @@ export class BookingTransitionService {
resource: "bookings",
code: file.fieldname,
file,
// Ad-hoc uploads carry the name the customer typed (fieldname
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
// of a raw filename like "scan_003.pdf".
title: adHocLabel(file.fieldname),
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith("custom_")

View File

@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
if (booking.paymentStatus === 'PAID') return false;
return true;
}
/**
* The label the customer typed for an ad-hoc clearance document, recovered from
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
* back to the filename.
*/
export function adHocLabel(fileKey: string): string | null {
const m = /^custom_(.+)_\d+$/.exec(fileKey);
if (!m) return null;
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
// of digits. Those carry no name — reject them so the caller falls back to
// the filename instead of showing "1755780000000".
if (/^\d+$/.test(m[1])) return null;
const label = m[1].replace(/-/g, ' ').trim();
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
}

View File

@@ -23,7 +23,7 @@ import {
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
@@ -268,7 +268,9 @@ export class BookingClearanceService {
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
// What the customer called it, falling back to the filename for rows
// uploaded before the name was carried through.
label: f.title || adHocLabel(f.code) || f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
@@ -345,7 +347,8 @@ export class BookingClearanceService {
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
// Removed from the clearance flow — see gl-operations.service.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);

View File

@@ -330,7 +330,9 @@ export class ContractClearanceService {
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
// Removed from the clearance flow — see gl-operations.service. Kept in the
// payload (always null) so existing consumers keep type-checking.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
@@ -340,7 +342,6 @@ export class ContractClearanceService {
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);

View File

@@ -779,7 +779,15 @@ export class GlOperationsService {
};
}
/** Final-invoice state joined with its document + slip files, for clearance views. */
/**
* Final-invoice state joined with its document + slip files.
*
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
* longer part of the export process, is not rendered on either desk or the
* portal, and never gated anything downstream. The endpoints and this reader
* stay so already-issued invoices remain resolvable; nothing calls it from a
* clearance view any more.
*/
async finalInvoiceSummary(
bookingId: string,
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {

View File

@@ -1218,12 +1218,7 @@ export class BookingBatchService implements OnModuleInit {
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
@@ -4782,18 +4777,36 @@ export class BookingBatchService implements OnModuleInit {
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
excludeBookingIds?: string[],
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
// Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard.
const [wagonDims, allowed] = await Promise.all([
this.loadWagonDims(),
this.loadAllowedWagonTypeIds(),
]);
const anyType = [...stock.remainingByTypeId.keys()];
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
ledger.consume(
typeIds.length ? typeIds : anyType,
this.wagonsFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return ledger;
}
/**
@@ -4956,6 +4969,29 @@ export class BookingBatchService implements OnModuleInit {
// wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/**
* Every booking already holding capacity on the schedule: allocated (linked),
* live-reserved (unexpired pay window or paid), and pending export requests
* that named this train. The ONE list both the abstract corridor budget and
* the per-yard wagon-type ledger must debit — when only the budget saw them,
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
* still advertised "15 free" there, because the whole-train budget had room
* left on that edge (from the other yard's wagons) and the ledger was born
* full.
*/
private async committedBookings(
schedule: TrainSchedule,
excludeBookingIds?: string[],
): Promise<Booking[]> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
@@ -4988,13 +5024,14 @@ export class BookingBatchService implements OnModuleInit {
relations: ['bookingContainers'],
})
).filter((b) => !excludeBookingIds?.includes(b.id));
for (const b of [...allocated, ...reserved, ...pendingHolds]) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
// A booking can sit in more than one set (allocated AND still reserved);
// it holds its wagons once.
const seen = new Set<string>();
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
seen.add(b.id);
return true;
});
}
/**

View File

@@ -0,0 +1,115 @@
import { BookingBatchService } from './booking-batch.service';
import { CorridorBudget } from './corridor-capacity.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Regression: a train with 15 wagons planned at Mojo and a 15-wagon booking
* already committed from Mojo advertised "15 free at Mojo" — the whole-train
* corridor budget still had room on that edge (GMP's wagons), and the per-yard
* stock ledger was born full. The ledger must be debited by the SAME committed
* bookings the budget subtracts.
*/
describe('BookingBatchService — per-yard stock ledger debits committed bookings', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const booking = (id: string, originYardId: string, wagonsRequired: number) =>
({
id,
freightType: 'BULK',
cargoTypeId: 'ct-coffee',
wagonsRequired,
originYardId,
destinationYardId: DCT,
cargoTotalWeightVgm: 1,
bookingContainers: [],
}) as unknown as Booking;
const schedule = {
id: 'S-35',
routeId: 'route-1',
originStationId: GMP,
destinationStationId: DCT,
scheduleBookings: [{ booking: booking('BK-118', MOJO, 15) }, { booking: booking('BK-120', GMP, 1) }],
} as never;
const makeService = (pendingHolds: Booking[] = []) => {
const milestoneRepo = {
find: jest.fn().mockResolvedValue([
{ yardId: GMP, sequenceNo: 1 },
{ yardId: MOJO, sequenceNo: 2 },
{ yardId: DCT, sequenceNo: 3 },
]),
};
const emptyRepo = { find: jest.fn().mockResolvedValue([]) };
// Booking.find is only used for OPERATION_REQUEST_PENDING export holds.
const bookingRepo = { find: jest.fn().mockResolvedValue(pendingHolds) };
const dataSource = {
getRepository: jest.fn((entity: unknown) =>
entity === RouteMilestone ? milestoneRepo : entity === Booking ? bookingRepo : emptyRepo,
),
query: jest.fn(async (sql: string) =>
sql.includes('cargo_type_wagon_types') ? [{ typeId: 'ct-coffee', wagonTypeId: 'nw5' }] : [],
),
};
const trainSchedulingService = {
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'TRAIN',
remainingByTypeId: new Map([['nw5', 46]]),
codesByTypeId: new Map([['nw5', 'NW5']]),
byYardId: new Map([
[GMP, new Map([['nw5', 31]])],
[MOJO, new Map([['nw5', 15]])],
]),
}),
};
return new BookingBatchService(
dataSource as never,
{ findReservedForSchedule: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
trainSchedulingService as never,
{} as never,
{} as never,
{} as never,
);
};
const budget = () =>
new CorridorBudget([GMP, MOJO, DCT], {
wagons: 46,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
it('shows 0 free at Mojo once its 15 planned wagons are booked, while GMP keeps its own', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(0);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(30);
});
it('a pending export request already holds its wagons at its yard (before staff accept)', async () => {
const service = makeService([booking('BK-REQ', MOJO, 10)]) as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const emptySchedule = { ...(schedule as object), scheduleBookings: [] } as never;
const b = budget();
const ledger = await service.stockLedgerFor(emptySchedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(5);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(31);
});
it('excludes the booking being evaluated so a request never blocks its own accept', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b, ['BK-118']);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(15);
});
});

View File

@@ -28,20 +28,30 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
export function autoFillPlacements(
units: ContainerUnitForPlacement[],
containerSlots: number[],
/**
* TEU already taken per slot sequenceNo by placements the caller supplied.
* Without it a partial auto-fill restarted at wagon #1 and stacked a second
* 40ft onto a wagon another booking's placement had already filled.
*/
occupiedTeuBySlot: ReadonlyMap<number, number> = new Map(),
): ContainerPlacementInput[] {
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacementInput[] = [];
const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0;
let teuInCurrentSlot = 0;
let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0;
for (const unit of units) {
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
while (
teuInCurrentSlot > 0 &&
teuInCurrentSlot + teu > MAX_TEU_PER_WAGON &&
currentSlotIndex < containerSlots.length - 1
) {
currentSlotIndex += 1;
teuInCurrentSlot = 0;
teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0;
}
const sequenceNo =
@@ -62,6 +72,25 @@ export function autoFillPlacements(
return placements;
}
/** TEU per slot sequenceNo consumed by the given placements. */
export function occupiedTeuBySlot(
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
units: ContainerUnitForPlacement[],
): Map<number, number> {
const teuOfUnit = new Map(
units.map((u) => [
`${u.bookingContainerId}:${u.unitIndex}`,
u.teuSlots ?? (u.sizeFt && u.sizeFt >= 40 ? 2 : 1),
]),
);
const out = new Map<number, number>();
for (const p of placements) {
const teu = teuOfUnit.get(`${p.bookingContainerId}:${p.unitIndex}`) ?? 1;
out.set(p.sequenceNo, (out.get(p.sequenceNo) ?? 0) + teu);
}
return out;
}
export function findMissingContainerNumberIssues(
units: ContainerUnitForPlacement[],
placements: ContainerPlacementInput[],

View File

@@ -56,6 +56,14 @@ export class AssignBookingsDto {
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({
description:
'Linked bookings the plan cannot seat stay linked as WAITING_FOR_WAGON instead of failing the whole allocation (auto-allocation mode). Requested bookingIds still fail loudly.',
})
@IsOptional()
@IsBoolean()
keepDeferredLinked?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()

View File

@@ -199,6 +199,7 @@ import {
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
occupiedTeuBySlot,
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -1996,7 +1997,11 @@ export class TrainSchedulingService {
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const generated = autoFillPlacements(
unplacedUnits,
slots,
occupiedTeuBySlot(containerPlacements ?? [], units),
);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
@@ -2053,16 +2058,23 @@ export class TrainSchedulingService {
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
const dropped = allBookingIds.filter((id) => !plannedIds.has(id));
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const detailOf = (id: string) =>
reasonById.get(id) ?? `${id}: does not fit the train's wagon stock or capacity`;
// Auto-allocation (keepDeferredLinked): an ALREADY-LINKED booking the plan
// cannot seat — e.g. it boards at a yard whose planned wagons are all taken
// — stays linked and is parked WAITING_FOR_WAGON for staff, instead of
// aborting the whole rebuild and leaving every OTHER paid booking without a
// wagon too. Explicitly requested ids still fail loudly.
const keptDeferredIds = dto.keepDeferredLinked
? dropped.filter((id) => !dto.bookingIds.includes(id))
: [];
const droppedRequested = dropped.filter((id) => !keptDeferredIds.includes(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`,
);
const details = droppedRequested.map(detailOf);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
@@ -2072,6 +2084,9 @@ export class TrainSchedulingService {
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
for (const id of keptDeferredIds) {
warnings.push(`${detailOf(id)} — kept on the schedule, waiting for a wagon`);
}
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
@@ -2179,10 +2194,10 @@ export class TrainSchedulingService {
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
const scheduleBookingRecords = [
...bookings.map((booking) => booking.id),
...keptDeferredIds,
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
@@ -2208,6 +2223,13 @@ export class TrainSchedulingService {
manager,
);
}
for (const bookingId of keptDeferredIds) {
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus: SchedulingStatus.WaitingForWagon },
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
@@ -4928,6 +4950,7 @@ export class TrainSchedulingService {
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
stops,
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
@@ -9362,8 +9385,7 @@ export class TrainSchedulingService {
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
if (containerBookings.some((b) => assignableSet.has(b.id)) && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
@@ -9371,12 +9393,14 @@ export class TrainSchedulingService {
}
try {
// No placements handed over on purpose: the assign re-plans the WHOLE
// linked set (incl. non-eligible linked bookings such as one already in
// transit), so slot numbers from THIS preview would not line up with the
// plan it builds — it auto-fills every unit against its own plan instead.
// The preview placements above only serve the missing-number check.
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
{ bookingIds: assignableIds, keepDeferredLinked: true },
undefined,
);
result.assignedBookingIds = assignableIds;
@@ -10044,8 +10068,11 @@ export class TrainSchedulingService {
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
// Count the fleet at the yard the BOOKING boards from, not the train's
// origin: on a split consist a Mojo booking can only ride Mojo wagons, and
// Gelan's 31 spare wagons told a paying customer there was no shortage.
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
booking.originYardId ?? schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(

View File

@@ -443,3 +443,50 @@ describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', ()
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
});
});
describe('planWagonsWithStock — consist split across yards', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const boards = (id: string, quantity: number, originYardId: string): Booking =>
({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking;
const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() };
const legsFor = (bookings: Booking[]) =>
new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }]));
const splitStock = {
mode: 'TRAIN' as const,
remainingByTypeId: new Map([[nw6.id, 46]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
byYardId: new Map([
[GMP, new Map([[nw6.id, 31]])],
[MOJO, new Map([[nw6.id, 15]])],
]),
};
it('seats a boarding yard only from the wagons planned there', () => {
// 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons;
// BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits
// (the whole-train 46 is irrelevant).
const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']);
expect(result.deferred[0]!.reason).toContain('planned at the boarding yard');
expect(result.plan).toHaveLength(16);
});
it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => {
const stock = {
...splitStock,
remainingByTypeId: new Map([[nw6.id, 1]]),
byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]),
};
const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
// The Mojo wagon has TEU room, but it is not standing in Gelan.
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
});
});

View File

@@ -93,6 +93,12 @@ type OpenSlot = {
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
/**
* Boarding-yard pool this wagon was opened from — the yard the consist plans
* it at (`''` when the consist is not split across yards). A Mojo wagon
* cannot later be stretched back to board at Gelan.
*/
pool: string;
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -197,9 +203,19 @@ export function planWagonsWithStock(params: {
*/
legs?: Map<string, BookingLeg>;
edgeCount?: number;
/**
* Ordered corridor stop ids, parallel to the edges. Required for a consist
* split across yards (`stock.byYardId`): a booking then draws ONLY from the
* wagons planned at the yard it boards from (`stops[leg.from]`) — the
* whole-train count would happily plan 17 Mojo wagons on a train that has
* 15 there and 31 in Gelan, and the physical pin then fails after the
* customer has paid.
*/
stops?: readonly string[];
}): FlexPlanResult {
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const stops = params.stops ?? [];
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
@@ -214,32 +230,45 @@ export function planWagonsWithStock(params: {
};
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
// Wagons of a type in use per corridor edge. A type is available for a leg
// when its busiest edge WITHIN that leg still has stock spare — the max over
// edges is the number of physical wagons the type needs simultaneously.
// Split consist: each boarding yard is its own pool of steel (mirrors
// WagonStockLedger). Single-yard consist / loose yard pool: one pool ''.
const poolOf = (leg: BookingLeg): string =>
stock.byYardId ? (stops[leg.from] ?? '') : '';
const rowKeyFor = (wagonTypeId: string, pool: string): string =>
pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
const totalFor = (wagonTypeId: string, pool: string): number =>
pool
? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0)
: (stock.remainingByTypeId.get(wagonTypeId) ?? 0);
// Wagons of a type in use per corridor edge, per pool. A type is available
// for a leg when its busiest edge WITHIN that leg still has stock spare — the
// max over edges is the number of physical wagons the type needs simultaneously.
const usedPerEdge = new Map<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
const usedRow = (key: string): number[] => {
let row = usedPerEdge.get(key);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
usedPerEdge.set(key, row);
}
return row;
};
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);
const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool));
if (!row) return total;
let busiest = 0;
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
return total - busiest;
};
const noStockMessage = (candidates: WagonType[]): string => {
const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`;
return poolOf(leg)
? `Train has no free ${codes} wagon planned at the boarding yard`
: `Train has no free ${codes} wagon left`;
};
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
@@ -251,7 +280,7 @@ export function planWagonsWithStock(params: {
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
@@ -261,7 +290,8 @@ export function planWagonsWithStock(params: {
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
const row = usedRow(chosen.id);
const pool = poolOf(leg);
const row = usedRow(rowKeyFor(chosen.id, pool));
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
@@ -271,6 +301,7 @@ export function planWagonsWithStock(params: {
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
pool,
};
openSlots.push(open);
return open;
@@ -290,8 +321,11 @@ export function planWagonsWithStock(params: {
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
// A pooled wagon boards where its yard is; it cannot be stretched back to
// an EARLIER stop (the steel is not there), only ridden further.
if (open.pool && leg.from < open.covered.from) return false;
const total = totalFor(open.slot.wagonTypeId, open.pool);
const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
@@ -303,7 +337,7 @@ export function planWagonsWithStock(params: {
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class SetTrainWagonsYardDto {
@ApiProperty({
type: [String],
format: 'uuid',
description:
'Coupled wagons to relocate. All move in one transaction — if any is pinned to a live schedule, none move.',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
@ApiProperty({
format: 'uuid',
description: 'Yard the selected wagons now sit in. The train itself stays put.',
})
@IsUUID()
currentYardId!: string;
}

View File

@@ -27,6 +27,7 @@ import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { SetTrainWagonsYardDto } from './dto/set-train-wagons-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@@ -128,6 +129,25 @@ export class TrainBuilderController {
);
}
@Patch(':id/wagons/yard')
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
@ApiOperation({
summary:
'Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule',
})
setWagonsYard(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetTrainWagonsYardDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.setWagonsYard(
id,
dto.wagonIds,
dto.currentYardId,
resolveAuthUserId(user),
);
}
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -7,6 +7,9 @@ import {
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
/** Schedule whose FULL flag must be re-derived once the consist edit has committed. */
type PendingWindowCheck = { scheduleId: string; wasFull: boolean };
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
@@ -550,15 +553,79 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Move SEVERAL coupled wagons to another yard in one transaction (the train
* and the rest of the consist stay put). All-or-nothing: if any wagon is not
* coupled here, or is pinned to a live schedule, nothing moves — a partial
* relocation would leave the consist split across yards silently. Wagons
* already in the target yard are skipped, not an error.
*/
async setWagonsYard(
id: string,
wagonIds: string[],
currentYardId: string,
userId?: string | null,
) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
const unique = [...new Set(wagonIds)];
const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) });
const byId = new Map(wagons.map((w) => [w.id, w]));
const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id);
if (missing.length) {
throw new NotFoundException(
`${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`,
);
}
// Check every wagon before moving any — the whole point of the bulk call.
const pinned: string[] = [];
for (const wagon of wagons) {
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
pinned.push(wagon.wagonNumber);
}
}
if (pinned.length) {
throw new ConflictException(
`${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${
pinned.length === 1 ? 'its' : 'their'
} yard cannot be changed`,
);
}
const moving = wagons.filter((w) => w.currentYardId !== yard.id);
if (!moving.length) return;
await manager
.getRepository(Wagon)
.update(moving.map((w) => w.id), { currentYardId: yard.id });
await manager.getRepository(WagonMovement).save(
moving.map((w) =>
manager.getRepository(WagonMovement).create({
wagonId: w.id,
fromYardId: w.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
),
);
});
return this.getComposition(id);
}
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
@@ -566,12 +633,13 @@ export class TrainBuilderService {
train.currentYardId ?? null,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
@@ -586,7 +654,7 @@ export class TrainBuilderService {
exportTrainNumber: null,
});
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
@@ -594,6 +662,7 @@ export class TrainBuilderService {
wagon.currentYardId ?? train.currentYardId ?? null,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
@@ -608,7 +677,7 @@ export class TrainBuilderService {
userId?: string | null,
note?: string | null,
) {
await this.dataSource.transaction(async (manager) => {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
@@ -663,7 +732,7 @@ export class TrainBuilderService {
);
}
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
@@ -671,6 +740,7 @@ export class TrainBuilderService {
yardId,
);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
@@ -1000,8 +1070,8 @@ export class TrainBuilderService {
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
): Promise<void> {
if (!changes.length) return;
): Promise<PendingWindowCheck | null> {
if (!changes.length) return null;
const trainSet = await manager
.getRepository(TrainSet)
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
@@ -1027,7 +1097,7 @@ export class TrainBuilderService {
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return;
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
@@ -1047,16 +1117,31 @@ export class TrainBuilderService {
),
);
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
// schedule reopens its booking window; filling the last one closes it.
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
// The FULL/reopen decision must run AFTER the transaction commits — see
// reconcileWindowAfterConsistChange.
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
}
/**
* Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
* schedule reopens its booking window; filling the last one closes it.
*
* Runs only once the consist transaction has COMMITTED. BookingBatchService
* reads through its own connection, so inside the transaction it still saw
* the old consist: a wagon coupled onto an empty (FULL) train counted as 0
* slots, `nowFull` stayed true and the window was never reopened.
*/
private async reconcileWindowAfterConsistChange(
pending: PendingWindowCheck | null,
): Promise<void> {
if (!pending) return;
const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId);
if (!usage) return;
const nowFull = usage.remainingSlots <= 0;
if (wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(schedule.id);
} else if (!wasFull && nowFull) {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
if (pending.wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(pending.scheduleId);
} else if (!pending.wasFull && nowFull) {
await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL');
}
}

View File

@@ -197,6 +197,11 @@ export class WagonsService {
if (dto.currentYardId !== undefined) {
wagon.currentYard = null;
}
// Same trap for `wagonType`: the stale eager-loaded relation would win over
// the new `wagonTypeId` and the type change would silently not persist.
if (dto.wagonTypeId !== undefined) {
wagon.wagonType = undefined;
}
await this.wagonRepo.save(wagon);
// Staff manually relocated the wagon — write the movement ledger row so the
// wagon's yard history stays auditable (who moved it, from where, when).