mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge pull request #890 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
computeBaseRailLinesWithRates: (
|
computeBaseRailLinesWithRates: (
|
||||||
b: Booking,
|
b: Booking,
|
||||||
input: { containers: [] },
|
input: { containers: [] },
|
||||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||||||
}
|
}
|
||||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||||
|
|
||||||
expect(result.lineItems).toHaveLength(0);
|
expect(result.lineItems).toHaveLength(0);
|
||||||
|
expect(result.blocked).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not price containers off a rate configured for a different leg', async () => {
|
it('does not price containers off a rate configured for a different leg', async () => {
|
||||||
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
|
|
||||||
expect(result.lineItems).toHaveLength(0);
|
expect(result.lineItems).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A mixed booking where only one container size has a configured rate must
|
||||||
|
// hard-block, not silently carry the unconfigured size for free.
|
||||||
|
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||||||
|
const fortyOnly: Rate = {
|
||||||
|
...intercityContainerUsd,
|
||||||
|
id: 'rate-ct-40-only',
|
||||||
|
containerTypeId: 'ct-40',
|
||||||
|
} as Rate;
|
||||||
|
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: 'b-5',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'DOMESTIC',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
originYardId: MOJO,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
service as unknown as {
|
||||||
|
computeBaseRailLinesWithRates: (
|
||||||
|
b: Booking,
|
||||||
|
input: {
|
||||||
|
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||||
|
},
|
||||||
|
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||||||
|
}
|
||||||
|
).computeBaseRailLinesWithRates(booking, {
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-40', quantity: 2 },
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.lineItems).toHaveLength(1);
|
||||||
|
expect(result.blocked).toHaveLength(1);
|
||||||
|
expect(result.blocked[0]).toContain('rate is configured');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,8 +137,12 @@ export class BookingPricingService {
|
|||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
|
const {
|
||||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
lineItems: baseLines,
|
||||||
|
usedRates: baseRates,
|
||||||
|
warnings: baseWarnings,
|
||||||
|
blocked: baseBlocked,
|
||||||
|
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||||
for (const line of baseLines) {
|
for (const line of baseLines) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
total += line.amount;
|
total += line.amount;
|
||||||
@@ -248,7 +252,7 @@ export class BookingPricingService {
|
|||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: ruleResult.hardBlocked,
|
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -454,7 +458,12 @@ export class BookingPricingService {
|
|||||||
booking: Booking,
|
booking: Booking,
|
||||||
evalInput: BookingEvaluationInput,
|
evalInput: BookingEvaluationInput,
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
|
): Promise<{
|
||||||
|
lineItems: PriceLineItemDto[];
|
||||||
|
usedRates: Rate[];
|
||||||
|
warnings: string[];
|
||||||
|
blocked: string[];
|
||||||
|
}> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
@@ -477,6 +486,7 @@ export class BookingPricingService {
|
|||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
const usedRatesMap = new Map<string, Rate>();
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
const wagonCount = await this.resolveWagonCount(booking);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
@@ -500,11 +510,14 @@ export class BookingPricingService {
|
|||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
if (!rate && !frozen) {
|
if (!rate && !frozen) {
|
||||||
// Never price this line off another container type's (or another
|
// Never price this line off another container type's (or another
|
||||||
// route's) rate — an unpriced line with a warning is recoverable; a
|
// route's) rate, and never let an unpriced line through: a booking
|
||||||
// silently mischarged one is not.
|
// that ships a container type nobody configured a rate for would be
|
||||||
warnings.push(
|
// carried for free. Hard-block instead — the customer drops the line
|
||||||
|
// or EDR configures the rate.
|
||||||
|
blocked.push(
|
||||||
`No ${rateType} rate is configured for ${label} on this route — ` +
|
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||||||
'the line was not priced.',
|
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
|
||||||
|
'to configure its rate for this origin → destination.',
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -587,10 +600,18 @@ export class BookingPricingService {
|
|||||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
|
} else if (isBulk) {
|
||||||
|
// Same rule as container lines: bulk freight with no rate on this leg
|
||||||
|
// must not proceed unpriced.
|
||||||
|
blocked.push(
|
||||||
|
`No ${rateType} rate is configured for this route — the booking ` +
|
||||||
|
'cannot be priced. Ask EDR to configure the rate for this ' +
|
||||||
|
'origin → destination.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -320,6 +320,12 @@ export class ContractBookingService {
|
|||||||
await this.applyWeightResults(loaded);
|
await this.applyWeightResults(loaded);
|
||||||
}
|
}
|
||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
|
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
|
||||||
|
// a positive total, so the zero-price gate below misses it — enforce
|
||||||
|
// the pricing hard blocks first. The catch below rolls everything back.
|
||||||
|
if (computed.hardBlocked.length > 0) {
|
||||||
|
throw new BadRequestException(computed.hardBlocked.join('; '));
|
||||||
|
}
|
||||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||||
// matched the route/container (or the rate is unset), so the booking is not
|
// matched the route/container (or the rate is unset), so the booking is not
|
||||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||||
@@ -744,14 +750,19 @@ export class ContractBookingService {
|
|||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
// A zero price means no contract rate matches — roll the cargo back so
|
// A zero price means no contract rate matches — roll the cargo back so
|
||||||
// the instance stays CLEARANCE_READY and can be completed again once
|
// the instance stays CLEARANCE_READY and can be completed again once
|
||||||
// the contract rates are fixed (the clearance work is not lost).
|
// the contract rates are fixed (the clearance work is not lost). A
|
||||||
if (!(computed.totalAmount > 0)) {
|
// pricing hard block (e.g. one of two container sizes has no rate)
|
||||||
|
// rolls back the same way: a partially-priced total is positive but
|
||||||
|
// the booking must not proceed.
|
||||||
|
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||||
await this.bookingsRepository.deleteContainers(booking.id);
|
await this.bookingsRepository.deleteContainers(booking.id);
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
cargoTotalWeightVgm: 0,
|
cargoTotalWeightVgm: 0,
|
||||||
} as never);
|
} as never);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Booking price came out as 0 — no contract rate matches this ' +
|
computed.hardBlocked.length > 0
|
||||||
|
? computed.hardBlocked.join('; ')
|
||||||
|
: 'Booking price came out as 0 — no contract rate matches this ' +
|
||||||
'route/cargo. Set the contract rate and try again.',
|
'route/cargo. Set the contract rate and try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1896,7 +1907,10 @@ export class ContractBookingService {
|
|||||||
overweightSurchargeAmount,
|
overweightSurchargeAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
pairingErrors,
|
pairingErrors,
|
||||||
capacityErrors: [...scopeErrors, ...capacityErrors],
|
// Pricing hard blocks (missing rate for a container size / requested
|
||||||
|
// service) ride the capacity-errors channel so the form hard-blocks in
|
||||||
|
// the preview instead of failing at the create call.
|
||||||
|
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
|
||||||
containerClashErrors,
|
containerClashErrors,
|
||||||
spaceErrors,
|
spaceErrors,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
|
|||||||
'CONTAINER_OPENED',
|
'CONTAINER_OPENED',
|
||||||
'CONTAINER_DAMAGED',
|
'CONTAINER_DAMAGED',
|
||||||
'FLUID_LEAKING',
|
'FLUID_LEAKING',
|
||||||
|
'OTHER',
|
||||||
] as const;
|
] as const;
|
||||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
import type { BookingEvaluationInput } from './rule-engine.service';
|
||||||
|
import type { Rate } from './entities/rate.entity';
|
||||||
|
|
||||||
|
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||||
|
const hazardRate: Rate = {
|
||||||
|
id: 'rate-hazard',
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
rateValue: 50,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
let ratesRepo: { findLiveRates: jest.Mock };
|
||||||
|
let service: RuleEngineService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||||||
|
ratesRepo as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||||||
|
{} as never, // dataSource (unused by evaluate)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
totalWagons: 1,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||||||
|
const result = await service.evaluate(input({ isHazardous: true }));
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||||||
|
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||||||
|
const result = await service.evaluate(input({ isHazardous: true }));
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||||||
|
const result = await service.evaluate(input({}));
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||||||
|
const result = await service.evaluate(
|
||||||
|
input({
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 2,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 20,
|
||||||
|
reeferQuantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('reefer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||||
|
|
||||||
|
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||||
|
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||||
|
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -245,6 +249,48 @@ export class RuleEngineService {
|
|||||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A handling service the booking asks for (booking-level flag OR any
|
||||||
|
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||||||
|
// hard block — pricing would otherwise ship the service for free. System-
|
||||||
|
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||||
|
// exempt: the customer never opted into those, so they must not block.
|
||||||
|
const requestedServices: Array<{
|
||||||
|
trigger: RateTrigger;
|
||||||
|
wanted: boolean;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
wanted:
|
||||||
|
truthy(input.isHazardous) ||
|
||||||
|
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||||
|
label: 'hazardous cargo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'REEFER',
|
||||||
|
wanted:
|
||||||
|
hasReefer ||
|
||||||
|
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||||
|
label: 'refrigerated (reefer) cargo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'WITH_RETURN',
|
||||||
|
wanted:
|
||||||
|
truthy(input.withReturn) ||
|
||||||
|
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||||
|
label: 'empty-container return',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const svc of requestedServices) {
|
||||||
|
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||||
|
hardBlocked.push(
|
||||||
|
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||||
|
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||||
|
'ask EDR to configure its rate.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const rate of surchargeRates) {
|
for (const rate of surchargeRates) {
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
@@ -438,9 +484,6 @@ export class RuleEngineService {
|
|||||||
hasLashing: boolean;
|
hasLashing: boolean;
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
|
||||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
|
||||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
case 'HAZARDOUS':
|
case 'HAZARDOUS':
|
||||||
return truthy(state.isHazardous);
|
return truthy(state.isHazardous);
|
||||||
|
|||||||
@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('expireLeftoverExportDay — export day sweep', () => {
|
||||||
|
const exportSchedule = {
|
||||||
|
id: scheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-dest',
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
};
|
||||||
|
let unacceptedSpy: jest.SpyInstance;
|
||||||
|
let poolSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
unacceptedSpy = jest
|
||||||
|
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-export schedules', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue({
|
||||||
|
...exportSchedule,
|
||||||
|
direction: 'IMPORT',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers while another export train on the day can still take bookings', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers while a FULL train still has live pay windows', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'FULL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||||
|
{
|
||||||
|
paymentStatus: 'PENDING',
|
||||||
|
status: 'AWAITING_PAYMENT',
|
||||||
|
paymentDeadline: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'FULL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||||||
|
originYardId: 'yard-origin',
|
||||||
|
destinationYardId: 'yard-dest',
|
||||||
|
day: '2026-06-20',
|
||||||
|
});
|
||||||
|
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||||
const importGeneral = {
|
const importGeneral = {
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('acceptIntercity — export pay window expires at window close', () => {
|
||||||
|
const exportScheduleId = 'export-train';
|
||||||
|
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||||||
|
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||||||
|
|
||||||
|
const waiting = {
|
||||||
|
id: 'ic-1',
|
||||||
|
reference: 'IC-1',
|
||||||
|
isGovernment: false,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
trainScheduleId: null,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
let scheduleRepo: { findOne: jest.Mock };
|
||||||
|
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bookingRepo = dataSource.getRepository();
|
||||||
|
bookingRepo.findOne.mockResolvedValue(waiting);
|
||||||
|
scheduleRepo = { findOne: jest.fn() };
|
||||||
|
// reserve() reads the target schedule to clamp export deadlines — route
|
||||||
|
// TrainSchedule reads to their own repo, everything else stays as before.
|
||||||
|
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||||||
|
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the intercity pay deadline to the export window close', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
windowClosesAt: closesAt,
|
||||||
|
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.acceptIntercity(waiting, exportScheduleId);
|
||||||
|
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'ic-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
paymentDeadline: closesAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the plain payment window on import trains', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'import-train',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
windowClosesAt: closesAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.acceptIntercity(waiting, 'import-train');
|
||||||
|
|
||||||
|
const deadline = (
|
||||||
|
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||||||
|
).paymentDeadline;
|
||||||
|
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||||||
|
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
windowClosesAt: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptIntercity(waiting, exportScheduleId),
|
||||||
|
).rejects.toThrow(/window has closed/);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||||||
|
const lapsed = {
|
||||||
|
...(waiting as unknown as Record<string, unknown>),
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
trainScheduleId: exportScheduleId,
|
||||||
|
paymentDeadline: new Date(Date.now() - 1_000),
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-b',
|
||||||
|
priorityScore: 0,
|
||||||
|
wagonsRequired: 1,
|
||||||
|
} as unknown as Booking;
|
||||||
|
bookingsRepository.findReservedForSchedule
|
||||||
|
.mockResolvedValueOnce([lapsed])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||||||
|
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||||||
|
originStationId: 'yard-a',
|
||||||
|
destinationStationId: 'yard-b',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.settleDueReservations(exportScheduleId);
|
||||||
|
|
||||||
|
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'ic-1',
|
||||||
|
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('BookingBatchService — wagonsFor', () => {
|
describe('BookingBatchService — wagonsFor', () => {
|
||||||
|
|||||||
@@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (schedule && (await this.isTrainFull(schedule))) {
|
if (schedule && (await this.isTrainFull(schedule))) {
|
||||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||||
|
// This payment may have been the last live pay window on a now-full
|
||||||
|
// export day — the settle that normally re-runs the sweep finds nothing
|
||||||
|
// left to settle, so trigger it here.
|
||||||
|
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||||
@@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`— payment phase extended for them`,
|
`— payment phase extended for them`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The settle may have resolved the last pay window on a full export day
|
||||||
|
// (paid → allocated, and the top-up found nothing else that fits) — sweep
|
||||||
|
// the date's leftover bookings. Self-guarded: no-op for import/domestic
|
||||||
|
// and while any train on the day can still take bookings.
|
||||||
|
await this.expireLeftoverExportDay(scheduleId);
|
||||||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||||
// this) so one settle produces one push, after every allocation/expiry/
|
// this) so one settle produces one push, after every allocation/expiry/
|
||||||
// top-up extension for this schedule has been persisted.
|
// top-up extension for this schedule has been persisted.
|
||||||
@@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (schedule && (await this.isTrainFull(schedule))) {
|
if (schedule && (await this.isTrainFull(schedule))) {
|
||||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||||
|
// Same as the webhook path: a staff mark-paid can settle the last live
|
||||||
|
// pay window on a now-full export day — sweep the date's leftovers.
|
||||||
|
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||||
}
|
}
|
||||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||||
@@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (!schedule || !locomotive) return null;
|
if (!schedule || !locomotive) return null;
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
// Built trains use the leg-aware corridor budget too: the wagon planner
|
||||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
|
||||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
// that runs empty Gelan→Adama genuinely can carry an intercity booking
|
||||||
// train-wide isTrainFull / committedWagons finalize signal.
|
// there before its export cargo boards at Adama. A train full on one leg
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||||
collapseForBuiltTrain: true,
|
// of the ride-along flow.
|
||||||
});
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||||
|
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||||
|
// window — export bookings expire at close, so anything reserved onto the
|
||||||
|
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||||
|
// keeps the plain payment window; its cycles re-fill after settle.
|
||||||
|
const targetSchedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: scheduleId } });
|
||||||
|
if (targetSchedule?.direction === "EXPORT") {
|
||||||
|
const cutoff =
|
||||||
|
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||||
|
if (cutoff && cutoff.getTime() <= now.getTime()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Export booking window has closed — cannot open a pay window on this train",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cutoff && cutoff.getTime() < deadline.getTime()) {
|
||||||
|
deadline = new Date(cutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
status: "SELECTED_FOR_BATCH",
|
status: "SELECTED_FOR_BATCH",
|
||||||
@@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return leftovers.length;
|
return leftovers.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
|
||||||
|
* so nothing ever concluded its day: bookings still waiting when the trains
|
||||||
|
* filled up or the window closed stayed pending forever. Once every export
|
||||||
|
* train on this route-day is shut — window DONE, or FULL with no pay window
|
||||||
|
* still live that could lapse and free space — the date is dead: expire the
|
||||||
|
* un-accepted bookings staff can no longer accept AND the ready
|
||||||
|
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
|
||||||
|
* waiters). Runs at export window close and whenever an export train's
|
||||||
|
* fullness settles.
|
||||||
|
*/
|
||||||
|
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
const trains = (
|
||||||
|
await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === day,
|
||||||
|
);
|
||||||
|
for (const s of trains) {
|
||||||
|
// Any train still taking bookings keeps the date alive.
|
||||||
|
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
|
||||||
|
// A FULL train whose reservations are still inside their pay windows can
|
||||||
|
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
|
||||||
|
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.expireUnacceptedForRouteDay({
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
await this.expireLeftoverDayPool(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||||
@@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
limits: TrainLimits,
|
limits: TrainLimits,
|
||||||
wagonDims: WagonDims,
|
wagonDims: WagonDims,
|
||||||
opts?: { collapseForBuiltTrain?: boolean },
|
|
||||||
): Promise<CorridorBudget> {
|
): Promise<CorridorBudget> {
|
||||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||||
if (physicalWagons != null) {
|
if (physicalWagons != null) {
|
||||||
@@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||||||
// commits each booking to a wagon for the entire route — it never reloads a
|
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
|
||||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
// committedWagons already count it. When a caller opts in, collapse the
|
|
||||||
// corridor to a single whole-route edge so every booking (full-route OR
|
|
||||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
|
||||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
|
||||||
// leg it merely passes through, instead of over-promising the freed slots.
|
|
||||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
|
||||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
|
||||||
const stops =
|
|
||||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
|
||||||
? [schedule.originStationId, schedule.destinationStationId]
|
|
||||||
: await this.stopsForSchedule(schedule);
|
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
hasLiveReservations: jest.Mock;
|
hasLiveReservations: jest.Mock;
|
||||||
refreshWindowStatus: jest.Mock;
|
refreshWindowStatus: jest.Mock;
|
||||||
expireLeftoverDayPool: jest.Mock;
|
expireLeftoverDayPool: jest.Mock;
|
||||||
|
expireLeftoverExportDay: jest.Mock;
|
||||||
fillFromWaitingList: jest.Mock;
|
fillFromWaitingList: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
@@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||||
|
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||||
schedule.bookingWindowStatus = 'CLOSED';
|
schedule.bookingWindowStatus = 'CLOSED';
|
||||||
}
|
}
|
||||||
|
// Export has no conclude step: this close is the last moment the day's
|
||||||
|
// bookings could have boarded. Once every train on the route-day is
|
||||||
|
// shut, expire what is still waiting for this date (the sweep defers
|
||||||
|
// while a sibling train stays open).
|
||||||
|
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class MoveContainerItemDto {
|
||||||
|
@IsUUID()
|
||||||
|
targetTrainSetWagonId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap with this container on the target wagon instead of requiring free
|
||||||
|
* space there. Same-wagon swaps exchange the two slot positions.
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
swapWithItemId?: string;
|
||||||
|
}
|
||||||
@@ -146,10 +146,8 @@ export class IntercityService {
|
|||||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||||
candidates: waiting.map((booking) => {
|
candidates: waiting.map((booking) => {
|
||||||
const need = capacity?.needFor(booking) ?? null;
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
// legForYards, not legOf: on a built train the budget is a single
|
// legForYards: the booking draws only from ITS OWN leg's edges, with a
|
||||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
// whole-route fallback when its yards aren't on the budget's stop list.
|
||||||
// must draw from that one pool via the whole-route fallback. On a
|
|
||||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
|
||||||
const leg = capacity?.budget.legForYards(
|
const leg = capacity?.budget.legForYards(
|
||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
@@ -215,11 +213,8 @@ export class IntercityService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const need = capacity.needFor(booking);
|
const need = capacity.needFor(booking);
|
||||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
// legForYards: charge only the edges this booking rides, so it can still
|
||||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
// board a train that is full only on other legs.
|
||||||
// reloaded), so the booking draws from that pool via the whole-route
|
|
||||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
|
||||||
// leg, so it can still board a train that is full only on other legs.
|
|
||||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
if (!budget.fits(need, leg)) {
|
if (!budget.fits(need, leg)) {
|
||||||
rejected.push({
|
rejected.push({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
|||||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||||
|
import { MoveContainerItemDto } from "./dto/move-container-item.dto";
|
||||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||||
@@ -374,6 +375,19 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/container-items/:itemId/move")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Move a container to another wagon (optionally swapping two containers)",
|
||||||
|
})
|
||||||
|
moveContainerItem(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("itemId", ParseUUIDPipe) itemId: string,
|
||||||
|
@Body() dto: MoveContainerItemDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.moveContainerItem(id, itemId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/unassigned-bookings")
|
@Get("schedules/:id/unassigned-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Wagon } from '../wagons/entities/wagon.entity';
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@@ -1081,4 +1082,143 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).not.toContain('EMPTY');
|
expect(html).not.toContain('EMPTY');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('moveContainerItem — staff rearrange', () => {
|
||||||
|
const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 };
|
||||||
|
const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 };
|
||||||
|
const schedule = {
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
trainSet: { wagons: [wagon1, wagon2] },
|
||||||
|
};
|
||||||
|
|
||||||
|
let sourceAlloc: Record<string, unknown>;
|
||||||
|
let item: Record<string, unknown>;
|
||||||
|
let itemRepo: { findOne: jest.Mock; update: jest.Mock; count: jest.Mock };
|
||||||
|
let allocRepo: {
|
||||||
|
find: jest.Mock;
|
||||||
|
findOne: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
save: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
delete: jest.Mock;
|
||||||
|
};
|
||||||
|
let wagon2Allocs: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sourceAlloc = {
|
||||||
|
id: 'alloc-1',
|
||||||
|
trainSetWagonId: 'w1',
|
||||||
|
bookingId: 'b1',
|
||||||
|
allocatedWeightTons: 20,
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
status: 'PLANNED',
|
||||||
|
containerItems: [],
|
||||||
|
};
|
||||||
|
item = {
|
||||||
|
id: 'item-1',
|
||||||
|
wagonBookingAllocationId: 'alloc-1',
|
||||||
|
positionOnWagon: 1,
|
||||||
|
grossWeightTons: 20,
|
||||||
|
containerType: { sizeFt: 20 },
|
||||||
|
allocation: sourceAlloc,
|
||||||
|
};
|
||||||
|
sourceAlloc.containerItems = [item];
|
||||||
|
wagon2Allocs = [];
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
||||||
|
itemRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(item),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
allocRepo = {
|
||||||
|
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||||||
|
Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs),
|
||||||
|
),
|
||||||
|
findOne: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) =>
|
||||||
|
Promise.resolve(where.id === 'alloc-1' ? { ...sourceAlloc } : null),
|
||||||
|
),
|
||||||
|
create: jest.fn((v: unknown) => v),
|
||||||
|
save: jest.fn().mockImplementation((v: Record<string, unknown>) =>
|
||||||
|
Promise.resolve({ ...v, id: 'alloc-new' }),
|
||||||
|
),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === WagonAllocationContainerItem) return itemRepo;
|
||||||
|
if (entity === WagonBookingAllocation) return allocRepo;
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
});
|
||||||
|
dataSource.transaction.mockImplementation(
|
||||||
|
async (fn: (m: unknown) => Promise<void>) =>
|
||||||
|
fn({ getRepository: dataSource.getRepository }),
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||||
|
'getTrainScheduleById' as never,
|
||||||
|
)
|
||||||
|
.mockResolvedValue({ id: 'sched-1' } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects moves on a dispatched train', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
...schedule,
|
||||||
|
status: 'DISPATCHED',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a target wagon that has no TEU room left', async () => {
|
||||||
|
wagon2Allocs = [
|
||||||
|
{
|
||||||
|
id: 'alloc-2',
|
||||||
|
trainSetWagonId: 'w2',
|
||||||
|
bookingId: 'b2',
|
||||||
|
allocatedWeightTons: 25,
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await expect(
|
||||||
|
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
|
||||||
|
).rejects.toThrow(/no room/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bulk-loaded target wagon', async () => {
|
||||||
|
wagon2Allocs = [
|
||||||
|
{
|
||||||
|
id: 'alloc-2',
|
||||||
|
trainSetWagonId: 'w2',
|
||||||
|
bookingId: 'b2',
|
||||||
|
allocatedWeightTons: 40,
|
||||||
|
loadType: 'BULK',
|
||||||
|
containerItems: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await expect(
|
||||||
|
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
|
||||||
|
).rejects.toThrow(/bulk/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a container to an empty wagon and re-homes its allocation', async () => {
|
||||||
|
await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' });
|
||||||
|
|
||||||
|
// A new allocation for the booking was created on the target wagon…
|
||||||
|
expect(allocRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }),
|
||||||
|
);
|
||||||
|
// …the container item now hangs off it…
|
||||||
|
expect(itemRepo.update).toHaveBeenCalledWith('item-1', {
|
||||||
|
wagonBookingAllocationId: 'alloc-new',
|
||||||
|
});
|
||||||
|
// …and the emptied source allocation was deleted, not left at 0 items.
|
||||||
|
expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
TrainScheduleFreightType,
|
TrainScheduleFreightType,
|
||||||
} from './dto/list-train-schedules-query.dto';
|
} from './dto/list-train-schedules-query.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||||
|
import { MoveContainerItemDto } from './dto/move-container-item.dto';
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
@@ -122,7 +123,7 @@ import {
|
|||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
type TrainLimitConfig,
|
type TrainLimitConfig,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimits,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
@@ -1538,8 +1539,21 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rebuild below deletes EVERY schedule↔booking link row and recreates
|
||||||
|
// only what makes the new plan. Ride-along (intercity) bookings are linked
|
||||||
|
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
|
||||||
|
// workspace's picked ids, so planning from dto.bookingIds alone silently
|
||||||
|
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
|
||||||
|
// every list. Every (re)assignment therefore re-plans the WHOLE train:
|
||||||
|
// the requested ids plus everything currently linked.
|
||||||
|
const linkedRows =
|
||||||
|
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
|
||||||
|
const allBookingIds = [
|
||||||
|
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
|
||||||
|
];
|
||||||
|
|
||||||
const previewDto = {
|
const previewDto = {
|
||||||
bookingIds: dto.bookingIds,
|
bookingIds: allBookingIds,
|
||||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||||
originStationId: schedule.originStationId,
|
originStationId: schedule.originStationId,
|
||||||
destinationStationId: schedule.destinationStationId,
|
destinationStationId: schedule.destinationStationId,
|
||||||
@@ -1562,8 +1576,13 @@ export class TrainSchedulingService {
|
|||||||
// preview the wagon plan first, then lay containers into the plan's slots.
|
// preview the wagon plan first, then lay containers into the plan's slots.
|
||||||
// Without this the placement validator rejects container bookings outright
|
// Without this the placement validator rejects container bookings outright
|
||||||
// ("Container placements are required for container bookings").
|
// ("Container placements are required for container bookings").
|
||||||
|
// Callers hand-pick placements only for the bookings they know about; the
|
||||||
|
// union above may have folded in linked ride-alongs those placements never
|
||||||
|
// covered. Auto-fill whatever units are missing (all of them when no
|
||||||
|
// placements were sent at all) so the placement validator doesn't reject
|
||||||
|
// container bookings the caller couldn't have placed.
|
||||||
let containerPlacements = dto.containerPlacements;
|
let containerPlacements = dto.containerPlacements;
|
||||||
if (!containerPlacements?.length) {
|
{
|
||||||
const preview = await this.validateBookingsForScheduling(
|
const preview = await this.validateBookingsForScheduling(
|
||||||
previewDto,
|
previewDto,
|
||||||
freightType ?? null,
|
freightType ?? null,
|
||||||
@@ -1578,9 +1597,18 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (containerBookings.length) {
|
if (containerBookings.length) {
|
||||||
const units = expandBookingContainerUnits(containerBookings);
|
const units = expandBookingContainerUnits(containerBookings);
|
||||||
|
const providedKeys = new Set(
|
||||||
|
(containerPlacements ?? []).map(
|
||||||
|
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const unplacedUnits = units.filter(
|
||||||
|
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||||||
|
);
|
||||||
|
if (unplacedUnits.length) {
|
||||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||||
const generated = autoFillPlacements(units, slots);
|
const generated = autoFillPlacements(unplacedUnits, slots);
|
||||||
const missing = findMissingContainerNumberIssues(units, generated);
|
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: `Booking validation failed: ${missing
|
message: `Booking validation failed: ${missing
|
||||||
@@ -1589,7 +1617,8 @@ export class TrainSchedulingService {
|
|||||||
violations: missing.map((m) => m.issue),
|
violations: missing.map((m) => m.issue),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
containerPlacements = generated;
|
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1632,8 +1661,10 @@ export class TrainSchedulingService {
|
|||||||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||||||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||||||
// override it either.
|
// override it either.
|
||||||
|
// 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 plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||||
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
|
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
||||||
if (droppedRequested.length) {
|
if (droppedRequested.length) {
|
||||||
const reasonById = new Map(
|
const reasonById = new Map(
|
||||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||||
@@ -3852,25 +3883,24 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
await (async () => {
|
|
||||||
// Corridor-aware: a booking belongs on this train when its origin and
|
// Corridor-aware: a booking belongs on this train when its origin and
|
||||||
// destination lie on the schedule's stop list in order — sub-corridor
|
// destination lie on the schedule's stop list in order — sub-corridor
|
||||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
|
||||||
|
// stop list is also what makes the wagon plan leg-aware below.
|
||||||
let stops = [dto.originStationId, dto.destinationStationId];
|
let stops = [dto.originStationId, dto.destinationStationId];
|
||||||
if (targetScheduleId) {
|
if (targetScheduleId) {
|
||||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||||
if (target) stops = await this.stopYardsForSchedule(target);
|
if (target) stops = await this.stopYardsForSchedule(target);
|
||||||
}
|
}
|
||||||
return bookings.some((b) => {
|
if (
|
||||||
|
bookings.some((b) => {
|
||||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const fromIdx = stops.indexOf(b.originYardId);
|
const fromIdx = stops.indexOf(b.originYardId);
|
||||||
const toIdx = stops.indexOf(b.destinationYardId);
|
const toIdx = stops.indexOf(b.destinationYardId);
|
||||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||||
});
|
})
|
||||||
})()
|
|
||||||
) {
|
) {
|
||||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||||
}
|
}
|
||||||
@@ -3956,7 +3986,22 @@ export class TrainSchedulingService {
|
|||||||
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const planned = planWagonsWithStock({ bookings, allowed, stock });
|
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||||||
|
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||||||
|
const legByBookingId = new Map(
|
||||||
|
bookings.flatMap((b) => {
|
||||||
|
const from = stops.indexOf(b.originYardId);
|
||||||
|
const to = stops.indexOf(b.destinationYardId);
|
||||||
|
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const planned = planWagonsWithStock({
|
||||||
|
bookings,
|
||||||
|
allowed,
|
||||||
|
stock,
|
||||||
|
legs: legByBookingId,
|
||||||
|
edgeCount: Math.max(1, stops.length - 1),
|
||||||
|
});
|
||||||
violations.push(...planned.configIssues);
|
violations.push(...planned.configIssues);
|
||||||
const fittingBookings = planned.fitting;
|
const fittingBookings = planned.fitting;
|
||||||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||||||
@@ -4018,10 +4063,11 @@ export class TrainSchedulingService {
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
pushLimit(
|
pushLimit(
|
||||||
validateMixedTrainLimits(
|
validateMixedTrainLimitsPerEdge(
|
||||||
wagonPlan,
|
wagonPlan,
|
||||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||||
trainLimits,
|
trainLimits,
|
||||||
|
stops,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
@@ -6810,12 +6856,33 @@ export class TrainSchedulingService {
|
|||||||
status: sb.booking?.status ?? null,
|
status: sb.booking?.status ?? null,
|
||||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||||
freightType: sb.booking?.freightType ?? null,
|
freightType: sb.booking?.freightType ?? null,
|
||||||
|
// Which leg of the corridor this booking rides — the workspace can't
|
||||||
|
// tell a ride-along (intercity) or sub-corridor booking from through
|
||||||
|
// cargo without it.
|
||||||
|
tradeDirection: sb.booking?.tradeDirection ?? null,
|
||||||
|
originYardId: sb.booking?.originYardId ?? null,
|
||||||
|
destinationYardId: sb.booking?.destinationYardId ?? null,
|
||||||
|
origin:
|
||||||
|
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
|
||||||
|
destination:
|
||||||
|
sb.booking?.destinationYard?.label ??
|
||||||
|
sb.booking?.destinationYard?.code ??
|
||||||
|
null,
|
||||||
|
wagonsRequired:
|
||||||
|
sb.booking?.wagonsRequired != null
|
||||||
|
? Number(sb.booking.wagonsRequired)
|
||||||
|
: null,
|
||||||
|
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||||
|
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||||
// booking itself — staff flip it per booking in the workspace before
|
// booking itself — staff flip it per booking in the workspace before
|
||||||
// dispatch. Defaults UNLOADED for links written before the column.
|
// dispatch. Defaults UNLOADED for links written before the column.
|
||||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
|
// Ordered corridor stops (route milestones; falls back to the two
|
||||||
|
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||||
|
stops: this.mapScheduleStops(schedule),
|
||||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||||
// it "historical" and skip re-pin affordances.
|
// it "historical" and skip re-pin affordances.
|
||||||
@@ -6824,6 +6891,42 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||||
|
private mapScheduleStops(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
): Array<{ yardId: string; label: string }> {
|
||||||
|
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||||||
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||||
|
);
|
||||||
|
const raw = milestones.length >= 2
|
||||||
|
? milestones.map((m) => ({
|
||||||
|
yardId: m.yardId,
|
||||||
|
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
yardId: schedule.originStationId,
|
||||||
|
label:
|
||||||
|
schedule.originStation?.label ??
|
||||||
|
schedule.originStation?.code ??
|
||||||
|
schedule.originStationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
yardId: schedule.destinationStationId,
|
||||||
|
label:
|
||||||
|
schedule.destinationStation?.label ??
|
||||||
|
schedule.destinationStation?.code ??
|
||||||
|
schedule.destinationStationId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return raw.filter((stop) => {
|
||||||
|
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
||||||
|
seen.add(stop.yardId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private isHoldActive(booking: Booking): boolean {
|
private isHoldActive(booking: Booking): boolean {
|
||||||
if (!booking.holdExpiresAt) return false;
|
if (!booking.holdExpiresAt) return false;
|
||||||
return booking.holdExpiresAt.getTime() > Date.now();
|
return booking.holdExpiresAt.getTime() > Date.now();
|
||||||
@@ -7232,6 +7335,232 @@ export class TrainSchedulingService {
|
|||||||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff rearrange: move one container to another wagon of the same train, or
|
||||||
|
* swap two containers (cross-wagon, or same-wagon to exchange slot positions).
|
||||||
|
* Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two
|
||||||
|
* 20ft) and the wagon's rated payload is never exceeded — so a drag on the
|
||||||
|
* consist can't silently overload a wagon. Allocation rows follow the items:
|
||||||
|
* the booking gets an allocation on the target wagon (created if missing),
|
||||||
|
* weights shift with the container, and an allocation left with no items is
|
||||||
|
* deleted.
|
||||||
|
*/
|
||||||
|
async moveContainerItem(
|
||||||
|
scheduleId: string,
|
||||||
|
itemId: string,
|
||||||
|
dto: MoveContainerItemDto,
|
||||||
|
): Promise<any> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||||||
|
throw new BadRequestException('Cannot rearrange containers on a dispatched train');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wagonById = new Map((schedule.trainSet?.wagons ?? []).map((w) => [w.id, w]));
|
||||||
|
const itemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
|
||||||
|
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||||
|
|
||||||
|
const item = await itemRepo.findOne({
|
||||||
|
where: { id: itemId },
|
||||||
|
relations: { allocation: true, containerType: true },
|
||||||
|
});
|
||||||
|
const sourceWagon = item?.allocation
|
||||||
|
? wagonById.get(item.allocation.trainSetWagonId)
|
||||||
|
: undefined;
|
||||||
|
if (!item?.allocation || !sourceWagon) {
|
||||||
|
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
|
||||||
|
}
|
||||||
|
const targetWagon = wagonById.get(dto.targetTrainSetWagonId);
|
||||||
|
if (!targetWagon) {
|
||||||
|
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAllocations = (trainSetWagonId: string) =>
|
||||||
|
allocRepo.find({
|
||||||
|
where: { trainSetWagonId },
|
||||||
|
relations: { containerItems: { containerType: true } },
|
||||||
|
});
|
||||||
|
const [sourceAllocs, targetAllocs] = await Promise.all([
|
||||||
|
loadAllocations(sourceWagon.id),
|
||||||
|
loadAllocations(targetWagon.id),
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
targetAllocs.some((a) => (a.loadType ?? '').toUpperCase().includes('BULK'))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon #${targetWagon.sequenceNo} carries a bulk load — containers cannot ride it`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapItem = dto.swapWithItemId
|
||||||
|
? targetAllocs
|
||||||
|
.flatMap((a) => a.containerItems ?? [])
|
||||||
|
.find((it) => it.id === dto.swapWithItemId)
|
||||||
|
: undefined;
|
||||||
|
if (dto.swapWithItemId && !swapItem) {
|
||||||
|
throw new BadRequestException('The container to swap with is not on the target wagon');
|
||||||
|
}
|
||||||
|
if (swapItem?.id === item.id) {
|
||||||
|
throw new BadRequestException('Cannot swap a container with itself');
|
||||||
|
}
|
||||||
|
if (sourceWagon.id === targetWagon.id && !swapItem) {
|
||||||
|
return this.getTrainScheduleById(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One
|
||||||
|
// wagon never exceeds 2 TEU — the same rule the auto-allocation packs by.
|
||||||
|
const MAX_TEU_PER_WAGON = 2;
|
||||||
|
const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) =>
|
||||||
|
(it.containerType?.sizeFt ?? 20) >= 40 ? 2 : 1;
|
||||||
|
const itemsOf = (allocs: WagonBookingAllocation[]) =>
|
||||||
|
allocs.flatMap((a) => a.containerItems ?? []);
|
||||||
|
// Weight a container carries into the move: its own gross when recorded,
|
||||||
|
// otherwise an even share of its allocation's weight.
|
||||||
|
const weightOf = (
|
||||||
|
it: WagonAllocationContainerItem,
|
||||||
|
alloc: WagonBookingAllocation,
|
||||||
|
siblings: number,
|
||||||
|
) =>
|
||||||
|
Number(it.grossWeightTons) ||
|
||||||
|
Number(alloc.allocatedWeightTons) / Math.max(1, siblings);
|
||||||
|
|
||||||
|
const sourceAlloc = sourceAllocs.find((a) => a.id === item.wagonBookingAllocationId);
|
||||||
|
if (!sourceAlloc) {
|
||||||
|
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
|
||||||
|
}
|
||||||
|
const itemWeight = weightOf(item, sourceAlloc, (sourceAlloc.containerItems ?? []).length);
|
||||||
|
const swapAlloc = swapItem
|
||||||
|
? targetAllocs.find((a) => a.id === swapItem.wagonBookingAllocationId)
|
||||||
|
: undefined;
|
||||||
|
const swapWeight =
|
||||||
|
swapItem && swapAlloc
|
||||||
|
? weightOf(swapItem, swapAlloc, (swapAlloc.containerItems ?? []).length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (sourceWagon.id !== targetWagon.id) {
|
||||||
|
const targetTeu = itemsOf(targetAllocs)
|
||||||
|
.filter((it) => it.id !== swapItem?.id)
|
||||||
|
.reduce((sum, it) => sum + teuOf(it), 0);
|
||||||
|
if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (swapItem) {
|
||||||
|
const sourceTeu = itemsOf(sourceAllocs)
|
||||||
|
.filter((it) => it.id !== item.id)
|
||||||
|
.reduce((sum, it) => sum + teuOf(it), 0);
|
||||||
|
if (sourceTeu + teuOf(swapItem) > MAX_TEU_PER_WAGON) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon #${sourceWagon.sequenceNo} has no room for the swapped container — a wagon holds one 40ft or two 20ft containers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargoOn = (allocs: WagonBookingAllocation[]) =>
|
||||||
|
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||||
|
const checkPayload = (
|
||||||
|
wagon: { sequenceNo: number; capacityTons?: number | null },
|
||||||
|
cargoAfter: number,
|
||||||
|
) => {
|
||||||
|
const capacity = Number(wagon.capacityTons ?? 0);
|
||||||
|
if (capacity > 0 && cargoAfter > capacity + 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight);
|
||||||
|
if (swapItem) {
|
||||||
|
checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const items = manager.getRepository(WagonAllocationContainerItem);
|
||||||
|
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||||
|
|
||||||
|
// Same-wagon swap: the containers only trade slot positions.
|
||||||
|
if (sourceWagon.id === targetWagon.id && swapItem) {
|
||||||
|
const a = item.positionOnWagon ?? null;
|
||||||
|
const b = swapItem.positionOnWagon ?? null;
|
||||||
|
await items.update(item.id, { positionOnWagon: b });
|
||||||
|
await items.update(swapItem.id, { positionOnWagon: a });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveOne = async (
|
||||||
|
moving: WagonAllocationContainerItem,
|
||||||
|
toWagonId: string,
|
||||||
|
weight: number,
|
||||||
|
) => {
|
||||||
|
// Re-read the source allocation — the other leg of a swap may have
|
||||||
|
// already shifted weight on it within this transaction.
|
||||||
|
const from = await allocs.findOne({
|
||||||
|
where: { id: moving.wagonBookingAllocationId },
|
||||||
|
});
|
||||||
|
if (!from) return;
|
||||||
|
let to = await allocs.findOne({
|
||||||
|
where: { trainSetWagonId: toWagonId, bookingId: from.bookingId },
|
||||||
|
});
|
||||||
|
if (!to) {
|
||||||
|
to = await allocs.save(
|
||||||
|
allocs.create({
|
||||||
|
trainSetWagonId: toWagonId,
|
||||||
|
bookingId: from.bookingId,
|
||||||
|
allocatedWeightTons: 0,
|
||||||
|
loadType: from.loadType ?? 'CONTAINER',
|
||||||
|
status: from.status ?? 'PLANNED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await items.update(moving.id, { wagonBookingAllocationId: to.id });
|
||||||
|
await allocs.update(to.id, {
|
||||||
|
allocatedWeightTons: roundTons(Number(to.allocatedWeightTons) + weight),
|
||||||
|
});
|
||||||
|
const remaining = await items.count({
|
||||||
|
where: { wagonBookingAllocationId: from.id },
|
||||||
|
});
|
||||||
|
if (remaining === 0) {
|
||||||
|
await allocs.delete(from.id);
|
||||||
|
} else {
|
||||||
|
await allocs.update(from.id, {
|
||||||
|
allocatedWeightTons: roundTons(
|
||||||
|
Math.max(0, Number(from.allocatedWeightTons) - weight),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await moveOne(item, targetWagon.id, itemWeight);
|
||||||
|
if (swapItem) {
|
||||||
|
await moveOne(swapItem, sourceWagon.id, swapWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep slot positions dense (1..n) on both touched wagons.
|
||||||
|
const renumber = async (trainSetWagonId: string) => {
|
||||||
|
const wagonAllocs = await allocs.find({
|
||||||
|
where: { trainSetWagonId },
|
||||||
|
relations: { containerItems: true },
|
||||||
|
});
|
||||||
|
const wagonItems = wagonAllocs
|
||||||
|
.flatMap((a) => a.containerItems ?? [])
|
||||||
|
.sort((x, y) => (x.positionOnWagon ?? 99) - (y.positionOnWagon ?? 99));
|
||||||
|
for (let i = 0; i < wagonItems.length; i += 1) {
|
||||||
|
if (wagonItems[i].positionOnWagon !== i + 1) {
|
||||||
|
await items.update(wagonItems[i].id, { positionOnWagon: i + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await renumber(sourceWagon.id);
|
||||||
|
await renumber(targetWagon.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getTrainScheduleById(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
|
|||||||
@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
|
|||||||
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
|
||||||
|
const allowed = {
|
||||||
|
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||||
|
byCargoTypeId: new Map(),
|
||||||
|
};
|
||||||
|
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
|
||||||
|
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
|
||||||
|
new Map(entries);
|
||||||
|
|
||||||
|
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
|
||||||
|
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
['EXPORT-1', { from: 1, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.fitting.map((b) => b.id).sort()).toEqual([
|
||||||
|
'EXPORT-1',
|
||||||
|
'INTERCITY-1',
|
||||||
|
]);
|
||||||
|
// Two slots planned, but both drawn from the single physical wagon.
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still defers when the legs overlap and stock is exhausted', () => {
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
// Both ride edge 0 — they compete for the one wagon.
|
||||||
|
['EXPORT-1', { from: 0, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
|
||||||
|
expect(result.deferred).toHaveLength(1);
|
||||||
|
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
|
||||||
|
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never packs bookings with different legs into the same wagon slot', () => {
|
||||||
|
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
|
||||||
|
// must open separate slots (each with its own leg), not one mixed slot.
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 2]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
['EXPORT-1', { from: 1, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
const bookingsPerSlot = result.plan.map((s) =>
|
||||||
|
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
|
||||||
|
);
|
||||||
|
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.plan).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,8 +58,18 @@ type OpenSlot = {
|
|||||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
freeCapacityTons: number;
|
freeCapacityTons: number;
|
||||||
|
/**
|
||||||
|
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
||||||
|
* share a slot when their legs are identical — mixing corridors in one slot
|
||||||
|
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
||||||
|
* re-occupy edges the cargo never rides.
|
||||||
|
*/
|
||||||
|
legKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||||
|
export type BookingLeg = { from: number; to: number };
|
||||||
|
|
||||||
type PlacementProblem = {
|
type PlacementProblem = {
|
||||||
kind: 'config' | 'stock';
|
kind: 'config' | 'stock';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
|||||||
const shortageFor = (
|
const shortageFor = (
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
candidates: WagonType[],
|
candidates: WagonType[],
|
||||||
remaining: Map<string, number>,
|
availableOf: (wagonTypeId: string) => number,
|
||||||
): BookingWagonShortage => {
|
): BookingWagonShortage => {
|
||||||
const wagonsNeeded =
|
const wagonsNeeded =
|
||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
@@ -100,7 +110,7 @@ const shortageFor = (
|
|||||||
)
|
)
|
||||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||||
const wagonsAvailable = candidates.reduce(
|
const wagonsAvailable = candidates.reduce(
|
||||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
(sum, wt) => sum + availableOf(wt.id),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
|
|||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
allowed: AllowedWagonTypeMap;
|
allowed: AllowedWagonTypeMap;
|
||||||
stock: WagonStock;
|
stock: WagonStock;
|
||||||
|
/**
|
||||||
|
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||||||
|
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||||||
|
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||||||
|
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||||||
|
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||||||
|
*/
|
||||||
|
legs?: Map<string, BookingLeg>;
|
||||||
|
edgeCount?: number;
|
||||||
}): FlexPlanResult {
|
}): FlexPlanResult {
|
||||||
const { bookings, allowed, stock } = params;
|
const { bookings, allowed, stock, legs } = params;
|
||||||
const remaining = new Map(stock.remainingByTypeId);
|
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||||
const openSlots: OpenSlot[] = [];
|
const openSlots: OpenSlot[] = [];
|
||||||
const fitting: Booking[] = [];
|
const fitting: Booking[] = [];
|
||||||
const deferred: DeferredBookingRow[] = [];
|
const deferred: DeferredBookingRow[] = [];
|
||||||
const configIssues = new Set<string>();
|
const configIssues = new Set<string>();
|
||||||
|
|
||||||
|
const legFor = (booking: Booking): BookingLeg => {
|
||||||
|
const leg = legs?.get(booking.id);
|
||||||
|
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||||
|
return { from: 0, to: edgeCount };
|
||||||
|
}
|
||||||
|
return leg;
|
||||||
|
};
|
||||||
|
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.
|
||||||
|
const usedPerEdge = new Map<string, number[]>();
|
||||||
|
const usedRow = (wagonTypeId: string): number[] => {
|
||||||
|
let row = usedPerEdge.get(wagonTypeId);
|
||||||
|
if (!row) {
|
||||||
|
row = new Array<number>(edgeCount).fill(0);
|
||||||
|
usedPerEdge.set(wagonTypeId, row);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||||
|
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||||
|
const row = usedPerEdge.get(wagonTypeId);
|
||||||
|
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[]): string => {
|
||||||
const codes = candidates.map((wt) => wt.code).join('/');
|
const codes = candidates.map((wt) => wt.code).join('/');
|
||||||
return stock.mode === 'TRAIN'
|
return stock.mode === 'TRAIN'
|
||||||
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
|
|||||||
: `No available ${codes} wagon at the yard`;
|
: `No available ${codes} wagon at the yard`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||||
const openSlot = (
|
const openSlot = (
|
||||||
candidates: WagonType[],
|
candidates: WagonType[],
|
||||||
kind: SlotLoadType,
|
kind: SlotLoadType,
|
||||||
cargoTypeId: string | null,
|
cargoTypeId: string | null,
|
||||||
|
leg: BookingLeg,
|
||||||
): OpenSlot | PlacementProblem => {
|
): OpenSlot | PlacementProblem => {
|
||||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||||
if (!inStock.length) {
|
if (!inStock.length) {
|
||||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||||
}
|
}
|
||||||
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
|
|||||||
const chosen = [...inStock].sort((a, b) =>
|
const chosen = [...inStock].sort((a, b) =>
|
||||||
kind === 'BULK'
|
kind === 'BULK'
|
||||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||||
)[0];
|
)[0];
|
||||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
const row = usedRow(chosen.id);
|
||||||
|
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||||
const open: OpenSlot = {
|
const open: OpenSlot = {
|
||||||
slot: slotFromWagonType(chosen, kind),
|
slot: slotFromWagonType(chosen, kind),
|
||||||
teuUsed: 0,
|
teuUsed: 0,
|
||||||
kind,
|
kind,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
freeCapacityTons: Number(chosen.capacityTons),
|
freeCapacityTons: Number(chosen.capacityTons),
|
||||||
|
legKey: legKeyOf(leg),
|
||||||
};
|
};
|
||||||
openSlots.push(open);
|
openSlots.push(open);
|
||||||
return open;
|
return open;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||||
|
const leg = legFor(booking);
|
||||||
|
const legKey = legKeyOf(leg);
|
||||||
if (booking.freightType === 'CONTAINER') {
|
if (booking.freightType === 'CONTAINER') {
|
||||||
const units = expandBookingContainerUnits([booking]);
|
const units = expandBookingContainerUnits([booking]);
|
||||||
if (!units.length) {
|
if (!units.length) {
|
||||||
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
|
|||||||
let target = openSlots.find(
|
let target = openSlots.find(
|
||||||
(open) =>
|
(open) =>
|
||||||
open.kind === 'CONTAINER' &&
|
open.kind === 'CONTAINER' &&
|
||||||
|
open.legKey === legKey &&
|
||||||
allowedIds.has(open.slot.wagonTypeId) &&
|
allowedIds.has(open.slot.wagonTypeId) &&
|
||||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||||
);
|
);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
target = openedSlot;
|
target = openedSlot;
|
||||||
}
|
}
|
||||||
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
|
|||||||
for (const open of openSlots) {
|
for (const open of openSlots) {
|
||||||
if (remainingWeight <= 0) break;
|
if (remainingWeight <= 0) break;
|
||||||
if (open.kind !== 'BULK') continue;
|
if (open.kind !== 'BULK') continue;
|
||||||
|
if (open.legKey !== legKey) continue;
|
||||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||||
if (open.freeCapacityTons <= 0) continue;
|
if (open.freeCapacityTons <= 0) continue;
|
||||||
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (remainingWeight > 0 || !placedAnywhere) {
|
while (remainingWeight > 0 || !placedAnywhere) {
|
||||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||||
addAllocation(
|
addAllocation(
|
||||||
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
|
|||||||
|
|
||||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||||
const remainingSnapshot = new Map(remaining);
|
const usedSnapshot = new Map(
|
||||||
|
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||||||
|
);
|
||||||
const slotCountSnapshot = openSlots.length;
|
const slotCountSnapshot = openSlots.length;
|
||||||
const slotStateSnapshot = openSlots.map((open) => ({
|
const slotStateSnapshot = openSlots.map((open) => ({
|
||||||
teuUsed: open.teuUsed,
|
teuUsed: open.teuUsed,
|
||||||
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Roll back this booking's partial placements.
|
// Roll back this booking's partial placements.
|
||||||
remaining.clear();
|
usedPerEdge.clear();
|
||||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||||||
openSlots.length = slotCountSnapshot;
|
openSlots.length = slotCountSnapshot;
|
||||||
openSlots.forEach((open, index) => {
|
openSlots.forEach((open, index) => {
|
||||||
const snap = slotStateSnapshot[index];
|
const snap = slotStateSnapshot[index];
|
||||||
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||||
// remaining is rolled back here, so the shortage counts the stock this
|
// Usage is rolled back here, so the shortage counts the stock this
|
||||||
// booking actually saw — not what its own partial placement consumed.
|
// booking actually saw — not what its own partial placement consumed.
|
||||||
|
const bookingLeg = legFor(booking);
|
||||||
const shortage =
|
const shortage =
|
||||||
problem.kind === 'stock' && problem.candidates?.length
|
problem.kind === 'stock' && problem.candidates?.length
|
||||||
? shortageFor(booking, problem.candidates, remaining)
|
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||||||
|
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
deferred.push({
|
deferred.push({
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
|
|||||||
@@ -525,6 +525,40 @@ export function validateMixedTrainLimits(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||||||
|
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||||||
|
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||||||
|
* wagon on Gelan→Adama never counts against a train that is full only on
|
||||||
|
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||||||
|
*/
|
||||||
|
export function validateMixedTrainLimitsPerEdge(
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||||
|
limits: TrainLimitConfig | undefined,
|
||||||
|
stops: string[],
|
||||||
|
): string[] {
|
||||||
|
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||||
|
const lastIdx = stops.length - 1;
|
||||||
|
const spans = wagonPlan.map((slot) => {
|
||||||
|
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||||
|
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||||
|
// A yard missing from the stop list keeps the slot on the whole route.
|
||||||
|
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||||
|
});
|
||||||
|
const violations = new Set<string>();
|
||||||
|
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||||
|
const active = wagonPlan.filter(
|
||||||
|
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||||
|
);
|
||||||
|
if (!active.length) continue;
|
||||||
|
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||||
|
violations.add(violation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...violations];
|
||||||
|
}
|
||||||
|
|
||||||
export function validate20ftContainerRules(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
|
|||||||
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
||||||
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
||||||
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
||||||
|
{ value: "OTHER", label: "Other" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABEL: Record<Freight.IncidentType, string> = {
|
const LABEL: Record<Freight.IncidentType, string> = {
|
||||||
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
|
|||||||
CONTAINER_OPENED: "Container opened",
|
CONTAINER_OPENED: "Container opened",
|
||||||
CONTAINER_DAMAGED: "Container damaged",
|
CONTAINER_DAMAGED: "Container damaged",
|
||||||
FLUID_LEAKING: "Fluid leaking",
|
FLUID_LEAKING: "Fluid leaking",
|
||||||
|
OTHER: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
|||||||
* confirmed manually when the train is physically at the booking's origin /
|
* confirmed manually when the train is physically at the booking's origin /
|
||||||
* destination yard (the server validates against recorded checkpoints).
|
* destination yard (the server validates against recorded checkpoints).
|
||||||
*/
|
*/
|
||||||
|
/** Plain-language journey states for the accepted ride-along table. */
|
||||||
|
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
|
||||||
|
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
|
||||||
|
PAID: { label: "Paid — ready to load", color: "edr-green" },
|
||||||
|
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
|
||||||
|
COMPLETED: { label: "Delivered", color: "teal" },
|
||||||
|
};
|
||||||
|
|
||||||
export function IntercityRideAlongPanel({
|
export function IntercityRideAlongPanel({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
direction,
|
direction,
|
||||||
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const invalidate = () =>
|
// Accepting/loading/unloading a ride-along changes the schedule's booking
|
||||||
queryClient.invalidateQueries({
|
// list, the yard worklists AND this panel — refresh all three so the
|
||||||
|
// workspace board and yard-work tables never show a stale picture.
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const accept = useMutation(
|
const accept = useMutation(
|
||||||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||||
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
|
|||||||
<CorridorCell row={row} />
|
<CorridorCell row={row} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" variant="light">
|
<Badge
|
||||||
{row.status}
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
|
|||||||
customer={b.customer}
|
customer={b.customer}
|
||||||
weightTons={b.weightTons}
|
weightTons={b.weightTons}
|
||||||
status={b.status}
|
status={b.status}
|
||||||
|
intercity={b.tradeDirection === "DOMESTIC"}
|
||||||
|
leg={
|
||||||
|
b.origin &&
|
||||||
|
b.destination &&
|
||||||
|
(b.originYardId !== schedule.originStation?.id ||
|
||||||
|
b.destinationYardId !== schedule.destinationStation?.id)
|
||||||
|
? `${b.origin} → ${b.destination}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||||
right={
|
right={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
@@ -840,6 +849,8 @@ function BookingCard({
|
|||||||
status,
|
status,
|
||||||
loadingStatus,
|
loadingStatus,
|
||||||
waitingForWagon,
|
waitingForWagon,
|
||||||
|
intercity,
|
||||||
|
leg,
|
||||||
right,
|
right,
|
||||||
}: {
|
}: {
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -849,6 +860,10 @@ function BookingCard({
|
|||||||
loadingStatus?: "LOADED" | "UNLOADED";
|
loadingStatus?: "LOADED" | "UNLOADED";
|
||||||
/** Paid, but no wagon of the required type was free — waiting for one. */
|
/** Paid, but no wagon of the required type was free — waiting for one. */
|
||||||
waitingForWagon?: boolean;
|
waitingForWagon?: boolean;
|
||||||
|
/** DOMESTIC ride-along riding only part of this train's corridor. */
|
||||||
|
intercity?: boolean;
|
||||||
|
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
|
||||||
|
leg?: string | null;
|
||||||
right?: React.ReactNode;
|
right?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -874,6 +889,16 @@ function BookingCard({
|
|||||||
{reference}
|
{reference}
|
||||||
</Text>
|
</Text>
|
||||||
{status ? <BookingStatusBadge status={status} /> : null}
|
{status ? <BookingStatusBadge status={status} /> : null}
|
||||||
|
{intercity ? (
|
||||||
|
<Tooltip
|
||||||
|
label="Intercity ride-along — rides only its own leg of this train's corridor"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge size="sm" radius="sm" variant="filled" color="indigo">
|
||||||
|
Intercity
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
{waitingForWagon ? (
|
{waitingForWagon ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
||||||
@@ -907,6 +932,11 @@ function BookingCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
{leg ? (
|
||||||
|
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{leg}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
|
|||||||
import { Box, Package } from "lucide-react";
|
import { Box, Package } from "lucide-react";
|
||||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||||
|
|
||||||
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
type WagonSlot = (WagonPlanRow & {
|
||||||
|
physicalWagonNumber?: string | null;
|
||||||
|
tareWeightTons?: number | null;
|
||||||
|
}) | {
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
capacityTons: number;
|
capacityTons: number;
|
||||||
assignedWeightTons: number;
|
assignedWeightTons: number;
|
||||||
|
|||||||
@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const invalidate = () =>
|
// Loading/unloading changes booking status on the schedule detail and the
|
||||||
queryClient.invalidateQueries({
|
// intercity panel too — refresh all three so no surface shows a stale state.
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const load = useMutation(
|
const load = useMutation(
|
||||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -14,6 +15,15 @@ import { freightBrand } from "@/theme/freight-brand";
|
|||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||||
|
|
||||||
|
export interface ContainerMove {
|
||||||
|
itemId: string;
|
||||||
|
targetWagonId: string;
|
||||||
|
/** Present when the drop landed on another container — swap the two. */
|
||||||
|
swapWithItemId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragState = { itemId: string; sourceWagonId: string } | null;
|
||||||
|
|
||||||
interface InteractiveTrainConsistProps {
|
interface InteractiveTrainConsistProps {
|
||||||
wagons: Wagon[];
|
wagons: Wagon[];
|
||||||
locomotive: Locomotive | null | undefined;
|
locomotive: Locomotive | null | undefined;
|
||||||
@@ -23,8 +33,16 @@ interface InteractiveTrainConsistProps {
|
|||||||
onSelectWagon: (wagon: Wagon) => void;
|
onSelectWagon: (wagon: Wagon) => void;
|
||||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||||
highlightBookingId?: string | null;
|
highlightBookingId?: string | null;
|
||||||
|
/** Containers become draggable between wagons (drop on a container = swap). */
|
||||||
|
canRearrange?: boolean;
|
||||||
|
onMoveContainer?: (move: ContainerMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wagonItems = (wagon: Wagon) =>
|
||||||
|
(wagon.allocations ?? [])
|
||||||
|
.flatMap((a) => a.containerItems ?? [])
|
||||||
|
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
|
||||||
|
|
||||||
const CONTAINER_GRADIENTS = [
|
const CONTAINER_GRADIENTS = [
|
||||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||||
@@ -151,16 +169,27 @@ function WagonCar({
|
|||||||
selected,
|
selected,
|
||||||
highlighted,
|
highlighted,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
drag,
|
||||||
|
onDragChange,
|
||||||
|
onMoveContainer,
|
||||||
|
canRearrange,
|
||||||
}: {
|
}: {
|
||||||
wagon: Wagon;
|
wagon: Wagon;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
drag: DragState;
|
||||||
|
onDragChange: (drag: DragState) => void;
|
||||||
|
onMoveContainer?: (move: ContainerMove) => void;
|
||||||
|
canRearrange: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [dropHover, setDropHover] = useState(false);
|
||||||
const allocation = wagon.allocations?.[0];
|
const allocation = wagon.allocations?.[0];
|
||||||
const isEmpty = !allocation;
|
const isEmpty = !allocation;
|
||||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
const isBulk = (wagon.allocations ?? []).some((a) =>
|
||||||
|
(a.loadType ?? "").toUpperCase().includes("BULK"),
|
||||||
|
);
|
||||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||||
const tare = wagon.tareWeightTons ?? 0;
|
const tare = wagon.tareWeightTons ?? 0;
|
||||||
const assigned =
|
const assigned =
|
||||||
@@ -170,10 +199,19 @@ function WagonCar({
|
|||||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||||
|
|
||||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
const items = wagonItems(wagon);
|
||||||
(c) => c.containerNumber?.trim() || "—",
|
const blocks = items.slice(0, 2);
|
||||||
|
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
||||||
|
|
||||||
|
// Where a dragged container may land: another wagon, not bulk-loaded, with a
|
||||||
|
// free half (the API re-checks TEU/weight — this only paints the hint).
|
||||||
|
const dropEligible = Boolean(
|
||||||
|
drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2,
|
||||||
);
|
);
|
||||||
const blocks = containerNumbers.slice(0, 2);
|
const endDrag = () => {
|
||||||
|
onDragChange(null);
|
||||||
|
setDropHover(false);
|
||||||
|
};
|
||||||
|
|
||||||
const ringColor = selected
|
const ringColor = selected
|
||||||
? freightBrand.primary
|
? freightBrand.primary
|
||||||
@@ -189,6 +227,21 @@ function WagonCar({
|
|||||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (dropEligible) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDropHover(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropHover(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (dropEligible && drag) {
|
||||||
|
e.preventDefault();
|
||||||
|
onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id });
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
height: 70,
|
height: 70,
|
||||||
@@ -205,10 +258,16 @@ function WagonCar({
|
|||||||
: isEmpty
|
: isEmpty
|
||||||
? "none"
|
? "none"
|
||||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||||
|
outline: dropHover
|
||||||
|
? "2px solid var(--mantine-color-cyan-6)"
|
||||||
|
: dropEligible
|
||||||
|
? "2px dashed var(--mantine-color-cyan-4)"
|
||||||
|
: "none",
|
||||||
|
outlineOffset: 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
transition: "box-shadow 120ms ease",
|
transition: "box-shadow 120ms ease, outline-color 120ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* top accent strip */}
|
{/* top accent strip */}
|
||||||
@@ -274,9 +333,41 @@ function WagonCar({
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
{blocks.length ? (
|
||||||
|
blocks.map((item, i) => {
|
||||||
|
const isDragged = drag?.itemId === item.id;
|
||||||
|
const swapEligible = Boolean(drag && drag.itemId !== item.id);
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={i}
|
key={item.id}
|
||||||
|
draggable={canRearrange}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
// Firefox needs data set for the drag to start.
|
||||||
|
e.dataTransfer.setData("text/plain", item.id);
|
||||||
|
onDragChange({ itemId: item.id, sourceWagonId: wagon.id });
|
||||||
|
}}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (swapEligible) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (swapEligible && drag) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoveContainer?.({
|
||||||
|
itemId: drag.itemId,
|
||||||
|
targetWagonId: wagon.id,
|
||||||
|
swapWithItemId: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -289,13 +380,37 @@ function WagonCar({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "0 2px",
|
padding: "0 2px",
|
||||||
|
cursor: canRearrange ? "grab" : undefined,
|
||||||
|
opacity: isDragged ? 0.35 : 1,
|
||||||
|
transition: "opacity 120ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||||
{cn}
|
{item.containerNumber?.trim() || "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: CONTAINER_GRADIENTS[0],
|
||||||
|
border: `1px solid ${CONTAINER_BORDERS[0]}`,
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="8px" fw={700} c="white">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -447,7 +562,10 @@ export const InteractiveTrainConsist = ({
|
|||||||
selectedWagonId,
|
selectedWagonId,
|
||||||
onSelectWagon,
|
onSelectWagon,
|
||||||
highlightBookingId,
|
highlightBookingId,
|
||||||
|
canRearrange = false,
|
||||||
|
onMoveContainer,
|
||||||
}: InteractiveTrainConsistProps) => {
|
}: InteractiveTrainConsistProps) => {
|
||||||
|
const [drag, setDrag] = useState<DragState>(null);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -477,6 +595,10 @@ export const InteractiveTrainConsist = ({
|
|||||||
selected={selectedWagonId === wagon.id}
|
selected={selectedWagonId === wagon.id}
|
||||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||||
onSelect={() => onSelectWagon(wagon)}
|
onSelect={() => onSelectWagon(wagon)}
|
||||||
|
drag={drag}
|
||||||
|
onDragChange={setDrag}
|
||||||
|
onMoveContainer={onMoveContainer}
|
||||||
|
canRearrange={canRearrange}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
import { isAxiosError } from "axios";
|
||||||
|
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { TrainStatsBar } from "./TrainStatsBar";
|
import { TrainStatsBar } from "./TrainStatsBar";
|
||||||
import { WagonCard } from "./WagonCard";
|
import { WagonCard } from "./WagonCard";
|
||||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist";
|
||||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
|
|||||||
}: TrainConsistViewProps) => {
|
}: TrainConsistViewProps) => {
|
||||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const unassignMutation = useMutation(
|
const unassignMutation = useMutation(
|
||||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||||
@@ -54,9 +57,37 @@ export const TrainConsistView = ({
|
|||||||
const removeWagonMutation = useMutation(
|
const removeWagonMutation = useMutation(
|
||||||
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const moveContainerMutation = useMutation(
|
||||||
|
api.trainScheduling.moveContainerItem.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const trainSet = scheduleDetail.trainSet;
|
const trainSet = scheduleDetail.trainSet;
|
||||||
const wagons = trainSet?.wagons ?? [];
|
const wagons = trainSet?.wagons ?? [];
|
||||||
|
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
||||||
|
|
||||||
|
const handleMoveContainer = async (move: ContainerMove) => {
|
||||||
|
if (moveContainerMutation.isPending) return;
|
||||||
|
try {
|
||||||
|
await moveContainerMutation.mutateAsync({
|
||||||
|
scheduleId,
|
||||||
|
itemId: move.itemId,
|
||||||
|
targetTrainSetWagonId: move.targetWagonId,
|
||||||
|
swapWithItemId: move.swapWithItemId,
|
||||||
|
});
|
||||||
|
toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" });
|
||||||
|
} catch (error) {
|
||||||
|
const message = isAxiosError(error)
|
||||||
|
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
||||||
|
: null;
|
||||||
|
toast({
|
||||||
|
title: "Could not move container",
|
||||||
|
description: Array.isArray(message)
|
||||||
|
? message.join(", ")
|
||||||
|
: (message ?? "The move was rejected — check the wagon's space and load."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Join company/customer name from schedule bookings by booking id.
|
// Join company/customer name from schedule bookings by booking id.
|
||||||
const companyByBooking = useMemo(() => {
|
const companyByBooking = useMemo(() => {
|
||||||
@@ -152,13 +183,21 @@ export const TrainConsistView = ({
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||||
|
{canRearrange ? (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Drag a container to move it — drop on a container to swap
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
<LegendDot color="cyan" label="Container" />
|
<LegendDot color="cyan" label="Container" />
|
||||||
<LegendDot color="orange" label="Bulk" />
|
<LegendDot color="orange" label="Bulk" />
|
||||||
<LegendDot color="gray" label="Empty" dashed />
|
<LegendDot color="gray" label="Empty" dashed />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box p="md">
|
<Box p="md" style={{ opacity: moveContainerMutation.isPending ? 0.6 : 1 }}>
|
||||||
<InteractiveTrainConsist
|
<InteractiveTrainConsist
|
||||||
wagons={wagons}
|
wagons={wagons}
|
||||||
locomotive={trainSet?.locomotive}
|
locomotive={trainSet?.locomotive}
|
||||||
@@ -166,6 +205,8 @@ export const TrainConsistView = ({
|
|||||||
selectedWagonId={selectedWagonId}
|
selectedWagonId={selectedWagonId}
|
||||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||||
highlightBookingId={highlightBookingId}
|
highlightBookingId={highlightBookingId}
|
||||||
|
canRearrange={canRearrange && !moveContainerMutation.isPending}
|
||||||
|
onMoveContainer={(move) => void handleMoveContainer(move)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -178,7 +219,7 @@ export const TrainConsistView = ({
|
|||||||
Editing wagon #{selectedWagon.sequenceNo}
|
Editing wagon #{selectedWagon.sequenceNo}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Update container numbers or remove the booking
|
Update container numbers, move containers to another wagon, or remove the booking
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<WagonCard
|
<WagonCard
|
||||||
@@ -192,6 +233,8 @@ export const TrainConsistView = ({
|
|||||||
scheduleStatus={scheduleDetail.status}
|
scheduleStatus={scheduleDetail.status}
|
||||||
onRemoveBooking={handleRemoveBooking}
|
onRemoveBooking={handleRemoveBooking}
|
||||||
onRemoveWagon={handleRemoveWagon}
|
onRemoveWagon={handleRemoveWagon}
|
||||||
|
wagons={wagons}
|
||||||
|
onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : wagons.length ? (
|
) : wagons.length ? (
|
||||||
@@ -209,7 +252,8 @@ export const TrainConsistView = ({
|
|||||||
<MousePointerClick size={13} />
|
<MousePointerClick size={13} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Click a wagon in the train to edit container numbers or remove its booking.
|
Click a wagon to edit its containers — or drag a container between wagons to
|
||||||
|
rearrange the load.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
Building2,
|
Building2,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Fuel,
|
Fuel,
|
||||||
@@ -10,6 +24,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||||
|
import type { ContainerMove } from "./InteractiveTrainConsist";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
@@ -21,8 +36,17 @@ interface WagonCardProps {
|
|||||||
scheduleStatus?: string;
|
scheduleStatus?: string;
|
||||||
onRemoveBooking: (wagon: Wagon) => void;
|
onRemoveBooking: (wagon: Wagon) => void;
|
||||||
onRemoveWagon: (wagonId: string) => void;
|
onRemoveWagon: (wagonId: string) => void;
|
||||||
|
/** All wagons of the consist — targets for the per-container move menu. */
|
||||||
|
wagons?: Wagon[];
|
||||||
|
onMoveContainer?: (move: ContainerMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemCountOf = (w: Wagon) =>
|
||||||
|
(w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0);
|
||||||
|
|
||||||
|
const isBulkWagon = (w: Wagon) =>
|
||||||
|
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
||||||
|
|
||||||
export const WagonCard = ({
|
export const WagonCard = ({
|
||||||
wagon,
|
wagon,
|
||||||
company,
|
company,
|
||||||
@@ -30,6 +54,8 @@ export const WagonCard = ({
|
|||||||
scheduleStatus,
|
scheduleStatus,
|
||||||
onRemoveBooking,
|
onRemoveBooking,
|
||||||
onRemoveWagon,
|
onRemoveWagon,
|
||||||
|
wagons,
|
||||||
|
onMoveContainer,
|
||||||
}: WagonCardProps) => {
|
}: WagonCardProps) => {
|
||||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||||
const allocation = wagon.allocations?.[0];
|
const allocation = wagon.allocations?.[0];
|
||||||
@@ -108,7 +134,11 @@ export const WagonCard = ({
|
|||||||
Containers
|
Containers
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{allocation.containerItems.map((item, idx) => (
|
{allocation.containerItems.map((item, idx) => {
|
||||||
|
const targets = (wagons ?? []).filter(
|
||||||
|
(w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2,
|
||||||
|
);
|
||||||
|
return (
|
||||||
<Group key={item.id} gap={8} wrap="nowrap">
|
<Group key={item.id} gap={8} wrap="nowrap">
|
||||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -120,8 +150,51 @@ export const WagonCard = ({
|
|||||||
scheduleId={scheduleId}
|
scheduleId={scheduleId}
|
||||||
disabled={isDispatched}
|
disabled={isDispatched}
|
||||||
/>
|
/>
|
||||||
|
{!isDispatched && onMoveContainer ? (
|
||||||
|
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<Tooltip label="Move to another wagon" withArrow>
|
||||||
|
<ActionIcon variant="light" color="cyan" size="sm">
|
||||||
|
<ArrowLeftRight size={13} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Move to wagon</Menu.Label>
|
||||||
|
{targets.length ? (
|
||||||
|
targets.map((w) => {
|
||||||
|
const count = itemCountOf(w);
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={w.id}
|
||||||
|
onClick={() =>
|
||||||
|
onMoveContainer({
|
||||||
|
itemId: item.id,
|
||||||
|
targetWagonId: w.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
#{w.sequenceNo} ·{" "}
|
||||||
|
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light" color={count ? "cyan" : "gray"}>
|
||||||
|
{count ? `${count}/2` : "empty"}
|
||||||
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Menu.Item disabled>No wagon has free space</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -280,3 +280,135 @@ export function RouteCorridor({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
|
||||||
|
export type SegmentStripBooking = {
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-segment wagon occupancy along the corridor: which legs are full and
|
||||||
|
* which still run empty. Through cargo (unknown/off-route yards) occupies the
|
||||||
|
* whole corridor; a ride-along counts only on its own leg — this is what makes
|
||||||
|
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
|
||||||
|
* glance instead of two disconnected booking lists.
|
||||||
|
*/
|
||||||
|
export function SegmentOccupancyStrip({
|
||||||
|
stops,
|
||||||
|
bookings,
|
||||||
|
maxWagons,
|
||||||
|
}: {
|
||||||
|
stops: Array<{ yardId: string; label: string }>;
|
||||||
|
bookings: SegmentStripBooking[];
|
||||||
|
maxWagons?: number | null;
|
||||||
|
}) {
|
||||||
|
if (stops.length < 2) return null;
|
||||||
|
const lastIdx = stops.length - 1;
|
||||||
|
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||||
|
|
||||||
|
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||||
|
let cargo = 0;
|
||||||
|
let intercity = 0;
|
||||||
|
for (const b of bookings) {
|
||||||
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||||
|
const to =
|
||||||
|
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
|
||||||
|
lastIdx;
|
||||||
|
const rides = from <= edge && edge < (to > from ? to : lastIdx);
|
||||||
|
if (!rides) continue;
|
||||||
|
const wagons = Number(b.wagonsRequired) || 1;
|
||||||
|
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||||
|
else cargo += wagons;
|
||||||
|
}
|
||||||
|
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cap = Number(maxWagons) || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const used = seg.cargo + seg.intercity;
|
||||||
|
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
|
||||||
|
const full = cap != null && used >= cap;
|
||||||
|
return (
|
||||||
|
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
|
||||||
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
w={9}
|
||||||
|
h={9}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${freightBrand.primary}`,
|
||||||
|
background: i === 0 ? "white" : freightBrand.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.from.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
|
||||||
|
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
|
||||||
|
{used}
|
||||||
|
{cap ? `/${cap}` : ""} wagons
|
||||||
|
{full ? " · full" : ""}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--mantine-color-gray-2)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cap ? (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
|
||||||
|
background: freightBrand.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
|
||||||
|
background: "var(--mantine-color-indigo-6)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box style={{ width: pct ? `${pct}%` : 0 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.cargo} cargo
|
||||||
|
{seg.intercity > 0 ? (
|
||||||
|
<Text span size="xs" fw={700} c="indigo.7">
|
||||||
|
{" "}
|
||||||
|
· {seg.intercity} intercity
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i === segments.length - 1 ? (
|
||||||
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
w={9}
|
||||||
|
h={9}
|
||||||
|
style={{ borderRadius: 999, background: freightBrand.primary }}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.to.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||||
|
MOVE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||||
|
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}/move`,
|
||||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -4,3 +4,17 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human label for a trade direction — DOMESTIC reads "Intercity" everywhere. */
|
||||||
|
export function directionLabel(direction?: string | null): string {
|
||||||
|
switch (direction) {
|
||||||
|
case "IMPORT":
|
||||||
|
return "Import";
|
||||||
|
case "EXPORT":
|
||||||
|
return "Export";
|
||||||
|
case "DOMESTIC":
|
||||||
|
return "Intercity";
|
||||||
|
default:
|
||||||
|
return direction || "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -199,7 +200,7 @@ export default function ClearanceDocumentsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{c.tradeDirection}
|
{directionLabel(c.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
@@ -468,7 +469,7 @@ function ClearanceHero({
|
|||||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
>
|
>
|
||||||
{direction}
|
{directionLabel(direction)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{customs ? (
|
{customs ? (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -180,7 +181,7 @@ function CustomsBadge({ customs }: { customs: boolean }) {
|
|||||||
function DirectionIcon({ direction }: { direction: string }) {
|
function DirectionIcon({ direction }: { direction: string }) {
|
||||||
const isImport = direction === "IMPORT";
|
const isImport = direction === "IMPORT";
|
||||||
const Icon = isImport ? Truck : ShipWheel;
|
const Icon = isImport ? Truck : ShipWheel;
|
||||||
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
|
const label = directionLabel(direction);
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} withArrow>
|
<Tooltip label={label} withArrow>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -597,7 +598,7 @@ export default function ContractRequestDetailPage() {
|
|||||||
<SectionCard icon={Package} title="Cargo scope">
|
<SectionCard icon={Package} title="Cargo scope">
|
||||||
<Group gap="sm" mb="md">
|
<Group gap="sm" mb="md">
|
||||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||||
{contract.tradeDirection}
|
{directionLabel(contract.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||||
{contract.freightType}
|
{contract.freightType}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -299,7 +300,7 @@ export default function ContractRequestsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{c.tradeDirection}
|
{directionLabel(c.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -36,6 +38,8 @@ import {
|
|||||||
type GlClearanceUploadKind,
|
type GlClearanceUploadKind,
|
||||||
} from "@/components/contracts/GlClearanceUploadModal";
|
} from "@/components/contracts/GlClearanceUploadModal";
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||||
@@ -146,6 +150,9 @@ export default function GlClearanceDetailPage() {
|
|||||||
"vesselDepartureDate" in data.clearance
|
"vesselDepartureDate" in data.clearance
|
||||||
? (data.clearance.vesselDepartureDate ?? null)
|
? (data.clearance.vesselDepartureDate ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
// Incident reporting attaches to a booking; a contract-level clearance can
|
||||||
|
// only report against its linked booking once one exists.
|
||||||
|
const incidentBookingId = data.kind === "booking" ? id : linkedBookingId;
|
||||||
|
|
||||||
// The shipment booking instance backing this clearance (per-booking GENERAL
|
// The shipment booking instance backing this clearance (per-booking GENERAL
|
||||||
// customs). Bare until GL completes it: no cargo, no price.
|
// customs). Bare until GL completes it: no cargo, no price.
|
||||||
@@ -173,7 +180,7 @@ export default function GlClearanceDetailPage() {
|
|||||||
]}
|
]}
|
||||||
meta={
|
meta={
|
||||||
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
||||||
{data.tradeDirection}
|
{directionLabel(data.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
@@ -220,6 +227,11 @@ export default function GlClearanceDetailPage() {
|
|||||||
>
|
>
|
||||||
Customs documents (all steps)
|
Customs documents (all steps)
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
{incidentBookingId ? (
|
||||||
|
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||||
|
Incidents
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="workflow">
|
<Tabs.Panel value="workflow">
|
||||||
@@ -309,6 +321,19 @@ export default function GlClearanceDetailPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{incidentBookingId ? (
|
||||||
|
<Tabs.Panel value="incidents">
|
||||||
|
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Log container or seal issues discovered during clearance handling.
|
||||||
|
</Text>
|
||||||
|
<IncidentReportCard bookingId={incidentBookingId} />
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -249,7 +250,7 @@ function toContractRow(c: Freight.IContract): ContractRow {
|
|||||||
function DirectionIcon({ direction }: { direction: string }) {
|
function DirectionIcon({ direction }: { direction: string }) {
|
||||||
const isImport = direction === "IMPORT";
|
const isImport = direction === "IMPORT";
|
||||||
const Icon = isImport ? Truck : ShipWheel;
|
const Icon = isImport ? Truck : ShipWheel;
|
||||||
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
|
const label = directionLabel(direction);
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} withArrow>
|
<Tooltip label={label} withArrow>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
|
{/* {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -876,7 +876,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
>
|
>
|
||||||
Adjust consist
|
Adjust consist
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null} */}
|
||||||
{data.windowPhase === "DOC_REVIEW" ? (
|
{data.windowPhase === "DOC_REVIEW" ? (
|
||||||
<Button
|
<Button
|
||||||
color="yellow"
|
color="yellow"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWor
|
|||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import {
|
import {
|
||||||
RouteCorridor,
|
RouteCorridor,
|
||||||
|
SegmentOccupancyStrip,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
scheduleBrand,
|
scheduleBrand,
|
||||||
} from "@/components/trainScheduling/scheduleVisuals";
|
} from "@/components/trainScheduling/scheduleVisuals";
|
||||||
@@ -388,12 +389,22 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const unloadedCount = dispatchBookings.filter(
|
const unloadedCount = dispatchBookings.filter(
|
||||||
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
|
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
|
||||||
).length;
|
).length;
|
||||||
|
// Intercity ride-alongs load through the journey flow (Load at their origin
|
||||||
|
// yard), not the workspace toggle — dispatching before that leaves paid cargo
|
||||||
|
// stranded on the platform while its train departs.
|
||||||
|
const intercityNotLoadedCount = dispatchBookings.filter(
|
||||||
|
(b) =>
|
||||||
|
b.tradeDirection === "DOMESTIC" &&
|
||||||
|
!b.loadedAt &&
|
||||||
|
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
||||||
|
).length;
|
||||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
||||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
||||||
const loadingBlocksDispatch =
|
const loadingBlocksDispatch =
|
||||||
schedule.requiresLoadingConfirmation === true &&
|
schedule.requiresLoadingConfirmation === true &&
|
||||||
schedule.loadingConfirmed !== true;
|
schedule.loadingConfirmed !== true;
|
||||||
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
|
const hasDispatchWarnings =
|
||||||
|
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||||
|
|
||||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||||
@@ -916,6 +927,16 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||||
|
(schedule.bookings ?? []).some(
|
||||||
|
(b) => b.tradeDirection === "DOMESTIC",
|
||||||
|
) ? (
|
||||||
|
<SegmentOccupancyStrip
|
||||||
|
stops={schedule.stops ?? []}
|
||||||
|
bookings={schedule.bookings ?? []}
|
||||||
|
maxWagons={schedule.maxWagons}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Box maw={340}>
|
<Box maw={340}>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
origin={
|
origin={
|
||||||
@@ -927,6 +948,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
<Group gap="sm" align="center">
|
<Group gap="sm" align="center">
|
||||||
<FreightTypeBadge freightType={schedule.freightType} />
|
<FreightTypeBadge freightType={schedule.freightType} />
|
||||||
<StatusPill status={schedule.status} />
|
<StatusPill status={schedule.status} />
|
||||||
@@ -1059,6 +1081,14 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
{
|
{
|
||||||
label: "Bookings",
|
label: "Bookings",
|
||||||
value: schedule.bookings?.length ?? 0,
|
value: schedule.bookings?.length ?? 0,
|
||||||
|
hint: (() => {
|
||||||
|
const intercity = (schedule.bookings ?? []).filter(
|
||||||
|
(b) => b.tradeDirection === "DOMESTIC",
|
||||||
|
).length;
|
||||||
|
return intercity > 0
|
||||||
|
? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}`
|
||||||
|
: undefined;
|
||||||
|
})(),
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1282,6 +1312,16 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
unloaded
|
unloaded
|
||||||
</List.Item>
|
</List.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{intercityNotLoadedCount > 0 ? (
|
||||||
|
<List.Item>
|
||||||
|
<Text span fw={700}>
|
||||||
|
{intercityNotLoadedCount}
|
||||||
|
</Text>{" "}
|
||||||
|
intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not
|
||||||
|
loaded yet — load them from the Workspace tab (Yard work) before
|
||||||
|
the train leaves their origin yard
|
||||||
|
</List.Item>
|
||||||
|
) : null}
|
||||||
</List>
|
</List>
|
||||||
<Text size="xs" c="dimmed" mt={6}>
|
<Text size="xs" c="dimmed" mt={6}>
|
||||||
You can still dispatch — confirm to proceed.
|
You can still dispatch — confirm to proceed.
|
||||||
|
|||||||
@@ -814,6 +814,26 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
moveContainerItem: endpoint<
|
||||||
|
{
|
||||||
|
scheduleId: string;
|
||||||
|
itemId: string;
|
||||||
|
targetTrainSetWagonId: string;
|
||||||
|
swapWithItemId?: string;
|
||||||
|
},
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"move-container-item",
|
||||||
|
({ scheduleId, itemId, targetTrainSetWagonId, swapWithItemId }) =>
|
||||||
|
trainSchedulingService.moveContainerItem(scheduleId, itemId, {
|
||||||
|
targetTrainSetWagonId,
|
||||||
|
swapWithItemId,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
warehouses: {
|
warehouses: {
|
||||||
|
|||||||
@@ -757,6 +757,18 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveContainerItem: async (
|
||||||
|
scheduleId: string,
|
||||||
|
itemId: string,
|
||||||
|
payload: { targetTrainSetWagonId: string; swapWithItemId?: string },
|
||||||
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_CONTAINER_ITEM(scheduleId, itemId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
getUnassignedBookings: async (
|
getUnassignedBookings: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<UnassignedBookingsResponse> => {
|
): Promise<UnassignedBookingsResponse> => {
|
||||||
|
|||||||
@@ -626,9 +626,20 @@ export interface TrainScheduleDetail {
|
|||||||
status: string | null;
|
status: string | null;
|
||||||
schedulingStatus?: SchedulingStatus | null;
|
schedulingStatus?: SchedulingStatus | null;
|
||||||
freightType?: FreightType | string | null;
|
freightType?: FreightType | string | null;
|
||||||
|
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
|
destination?: string | null;
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
loadedAt?: string | null;
|
||||||
|
arrivedAt?: string | null;
|
||||||
loadingStatus?: "LOADED" | "UNLOADED";
|
loadingStatus?: "LOADED" | "UNLOADED";
|
||||||
wagonAssigned?: boolean;
|
wagonAssigned?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||||
|
stops?: Array<{ yardId: string; label: string }>;
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ export const INCIDENT_TYPES = [
|
|||||||
"CONTAINER_OPENED",
|
"CONTAINER_OPENED",
|
||||||
"CONTAINER_DAMAGED",
|
"CONTAINER_DAMAGED",
|
||||||
"FLUID_LEAKING",
|
"FLUID_LEAKING",
|
||||||
|
"OTHER",
|
||||||
] as const;
|
] as const;
|
||||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user