mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user