mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
fix schule issue and contianer type issue
This commit is contained in:
@@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 210,
|
||||
bookingContainers: [
|
||||
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } },
|
||||
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
|
||||
],
|
||||
};
|
||||
expect(service.wagonsFor(booking, dims)).toBe(3);
|
||||
@@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 40,
|
||||
bookingContainers: [
|
||||
{ quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } },
|
||||
{ quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
};
|
||||
expect(service.wagonsFor(booking, dims)).toBe(2);
|
||||
@@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
{
|
||||
quantity: 2,
|
||||
wagonsRequired: 2,
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
// A schedule created from a built train is capped by its PHYSICAL consist:
|
||||
// wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the
|
||||
// old weight/length math would call every one of these trains FULL, so any
|
||||
// assertion below that says "not full" proves those axes are ignored.
|
||||
const scheduleId = 'schedule-built';
|
||||
|
||||
const reservedBooking = (id: string) =>
|
||||
({
|
||||
id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||||
bookingContainers: [],
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const buildService = (opts: {
|
||||
physicalWagons: number;
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose
|
||||
bookingWindowStatus: 'OPEN',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null,
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
maxPullWeightTons: 1,
|
||||
maxTrainLengthMeters: 1,
|
||||
overageToleranceTons: 0,
|
||||
overageToleranceMeters: 0,
|
||||
},
|
||||
train: { id: 'train-built-1' },
|
||||
},
|
||||
};
|
||||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||||
const genericRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) =>
|
||||
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
|
||||
),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
{
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved),
|
||||
} as never,
|
||||
{
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule),
|
||||
findById: jest.fn().mockResolvedValue(schedule),
|
||||
} as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
null as never,
|
||||
);
|
||||
return { service, wagonRepo };
|
||||
};
|
||||
|
||||
it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
maxWagons: 44, // stale: the old slot cap would say 42 slots remain
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 3,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
});
|
||||
// 1T pull cap would have been exhausted long ago under the old math.
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
});
|
||||
await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({
|
||||
maxWagons: 1,
|
||||
allocatedWagons: 2,
|
||||
remainingSlots: 0,
|
||||
overAllocatedBy: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
@@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
// forwardRef: TrainSchedulingService injects this service back (window
|
||||
// refresh after adjust-consist), so the classes load in a cycle.
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
@@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? Math.ceil(booking.wagonsRequired)
|
||||
: 0;
|
||||
|
||||
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
|
||||
// TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback
|
||||
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
|
||||
@@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep schedule.max_wagons aligned with the train's boarding limit: the
|
||||
* locomotive's length-derived slot count. The physical wagons currently in
|
||||
* the train set do NOT cap this — bookings are admitted on length/weight
|
||||
* alone and yard staff attach the wagons manually before departure.
|
||||
* Keep schedule.max_wagons aligned with the train's boarding limit. A built
|
||||
* train's limit is its physical consist — the wagon count staff marshalled
|
||||
* (and may change via adjust-consist). Only schedules WITHOUT a built train
|
||||
* fall back to the locomotive's length-derived slot count, where bookings
|
||||
* are admitted on length/weight alone and yard staff attach the wagons
|
||||
* manually before departure.
|
||||
*/
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
): Promise<void> {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const maxWagons = limits.base.wagons;
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
const maxWagons =
|
||||
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
|
||||
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||
*
|
||||
* The wagon axis is the locomotive's length-derived slot count only — the
|
||||
* physical wagons currently marshalled in the train set do NOT cap it.
|
||||
* Bookings are admitted on length/weight capacity and yard staff attach
|
||||
* the missing wagons manually before wagon assignment.
|
||||
* Two capacity regimes, decided by the schedule's train:
|
||||
* - Built train (Train Builder consist with physical wagons): the consist IS
|
||||
* the capacity. Wagon slots = physical wagon count; weight and length are
|
||||
* NOT re-checked here — the builder and adjust-consist already enforced the
|
||||
* locomotive's pull/length limits when the consist was assembled.
|
||||
* - No built train (legacy schedules): the locomotive's length-derived slot
|
||||
* count plus its weight/length budgets, as before — yard staff attach the
|
||||
* missing wagons manually before wagon assignment.
|
||||
*/
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
@@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons marshalled in the schedule's built train, or null when the
|
||||
* schedule has no built train (or the consist is still empty) and the legacy
|
||||
* locomotive-derived capacity must apply. This count is what caps a built
|
||||
* train's bookings: 50 wagons coupled → 50 wagon slots, no more.
|
||||
*/
|
||||
private async builtTrainWagonCount(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<number | null> {
|
||||
const trainId = schedule.trainSet?.train?.id;
|
||||
if (!trainId) return null;
|
||||
const count = await this.dataSource
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId } });
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
|
||||
@@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
|
||||
* train length for even one more loaded wagon. The old slot-only check let
|
||||
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
|
||||
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
|
||||
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
|
||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||
* the capacity, weight/length were settled at build time.
|
||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||
* pull weight / train length for even one more loaded wagon. The old
|
||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
||||
* could board.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
@@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return this.isTrainFull(schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the
|
||||
* schedule's slot capacity, how many slots allocated + reserved bookings
|
||||
* already hold on the busiest edge, how many are still free on the most-open
|
||||
* edge, and by how many slots the consist has been trimmed BELOW what is
|
||||
* already committed (0 when nothing is over-allocated).
|
||||
*/
|
||||
async scheduleWagonUsage(scheduleId: string): Promise<{
|
||||
maxWagons: number;
|
||||
allocatedWagons: number;
|
||||
remainingSlots: number;
|
||||
overAllocatedBy: number;
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) return null;
|
||||
const capacity =
|
||||
(await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
base: {
|
||||
wagons: capacity,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
},
|
||||
wagonDims,
|
||||
);
|
||||
const tightest = budget.remainingFor(budget.fullLeg()).wagons;
|
||||
return {
|
||||
maxWagons: capacity,
|
||||
allocatedWagons: capacity - tightest,
|
||||
remainingSlots: Math.max(0, budget.maxRemaining().wagons),
|
||||
overAllocatedBy: Math.max(0, -tightest),
|
||||
};
|
||||
}
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
// Built train: the physical consist is the only capacity axis. Weight and
|
||||
// length were enforced when the consist was assembled (builder /
|
||||
// adjust-consist), so a free wagon slot means the train genuinely has room.
|
||||
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
@@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
}
|
||||
|
||||
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
|
||||
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
|
||||
// wagon). Derived from containerType.sizeFt; falls back to the line's stored
|
||||
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -95,6 +97,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
summarizeFleetWarnings,
|
||||
@@ -317,6 +320,11 @@ export class TrainSchedulingService {
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||||
// existing specs that construct the service without it keep working.
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService?: BookingBatchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -5054,6 +5062,11 @@ export class TrainSchedulingService {
|
||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
|
||||
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
|
||||
// capacity, so trimming/coupling wagons moves the FULL line live.
|
||||
const wagonUsage =
|
||||
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
|
||||
|
||||
const mapWagon = (wagon: Wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
@@ -5092,6 +5105,12 @@ export class TrainSchedulingService {
|
||||
grossTons: roundTons(cargoTons + consistTareTons),
|
||||
consistLengthMeters,
|
||||
},
|
||||
scheduleCapacity: wagonUsage
|
||||
? {
|
||||
...wagonUsage,
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||
}
|
||||
: null,
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
@@ -5284,7 +5303,37 @@ export class TrainSchedulingService {
|
||||
);
|
||||
});
|
||||
|
||||
return this.getScheduleConsist(scheduleId);
|
||||
// The consist IS the schedule's booking capacity, so an edit moves the
|
||||
// FULL line: freeing slots on a FULL schedule reopens its window, taking
|
||||
// the last slot closes it. Staff may shrink below what is already
|
||||
// committed — allowed, but reported back as a warning (never silently).
|
||||
const warnings: string[] = [];
|
||||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||||
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
|
||||
if (usage) {
|
||||
const nowFull = usage.remainingSlots <= 0;
|
||||
if (usage.overAllocatedBy > 0) {
|
||||
warnings.push(
|
||||
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
|
||||
`${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
|
||||
'Couple more wagons or free bookings before departure.',
|
||||
);
|
||||
}
|
||||
if (wasFull && !nowFull) {
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
warnings.push(
|
||||
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
|
||||
'so it is no longer FULL and can take bookings again.',
|
||||
);
|
||||
} else if (!wasFull && nowFull) {
|
||||
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
|
||||
warnings.push(
|
||||
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => {
|
||||
fortyFooter.bookingContainers![0]!.containerType = {
|
||||
code: '40GP',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
} as never;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [fortyFooter],
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('wagon-plan.util', () => {
|
||||
});
|
||||
|
||||
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
||||
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
|
||||
// 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons
|
||||
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||
expect(sumWagonsRequired(booking)).toBe(3);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
@@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
|
||||
quantity,
|
||||
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
||||
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
});
|
||||
|
||||
it('20×20ft = 10 wagons (not 20)', () => {
|
||||
@@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
|
||||
});
|
||||
|
||||
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
|
||||
it('falls back to line wagonsRequired when containerType/sizeFt missing', () => {
|
||||
// No containerType relation loaded → use the stored (0.5-aware) fraction.
|
||||
expect(
|
||||
containerWagonsForLines([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { consistViolations } from './train-capacity.util';
|
||||
|
||||
@@ -61,7 +62,6 @@ export type ContainerUnitRow = {
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
@@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
type ContainerLine = {
|
||||
quantity?: number | null;
|
||||
wagonsRequired?: number | null;
|
||||
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
|
||||
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
|
||||
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
|
||||
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
|
||||
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
|
||||
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
|
||||
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
|
||||
* wagons, not 4).
|
||||
*/
|
||||
function lineWagonsRaw(line: ContainerLine): number {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
if (qty <= 0) return 0;
|
||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||
if (Number.isFinite(wpu) && wpu > 0) {
|
||||
return qty * wpu;
|
||||
const sizeFt = Number(line.containerType?.sizeFt);
|
||||
if (Number.isFinite(sizeFt) && sizeFt > 0) {
|
||||
return qty * wagonsPerUnitForSize(sizeFt);
|
||||
}
|
||||
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
|
||||
// treat the whole line as one wagon.
|
||||
// No size on the type: fall back to the line's stored fraction, else treat
|
||||
// the whole line as one wagon.
|
||||
const stored = Number(line.wagonsRequired);
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
||||
}
|
||||
@@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||||
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||||
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
|
||||
const perWagon = containersPerWagonFromType(wagonsPerUnit);
|
||||
const perWagon = containersPerWagonForSize(sizeFt);
|
||||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||
// The REAL per-container numbers/weights entered at booking time. Unit i of
|
||||
// the line maps to units[i] (sortOrder order); the line-level number is only
|
||||
@@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
|
||||
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||||
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
||||
sizeFt,
|
||||
wagonsPerUnit,
|
||||
containersPerWagon: perWagon,
|
||||
teuSlots,
|
||||
containerNumber:
|
||||
|
||||
Reference in New Issue
Block a user