mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
Merge pull request #799 from Tria-plc/freight_feature/usermanagement
add per-container handling options for hazardous, reefer, and return…
This commit is contained in:
@@ -305,6 +305,10 @@ export class BookingPricingService {
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||
},
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
|
||||
@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/** This container ships back empty after unloading (equipment return). */
|
||||
@Column({ name: 'is_return', type: 'boolean', default: false })
|
||||
isReturn!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
|
||||
@@ -40,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
@@ -283,8 +286,8 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
@@ -1451,6 +1454,53 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
* many units opted in. Forms that predate per-unit switches send line-level
|
||||
* counts and no unit flags — those are honoured as-is.
|
||||
*/
|
||||
private handlingCounts(line: CreateBookingContainerLineDto): {
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
returnQuantity: number;
|
||||
} {
|
||||
const units = line.units ?? [];
|
||||
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
|
||||
if (!flagged) {
|
||||
return {
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(line.returnQuantity ?? 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
hazardousQuantity: units.filter((u) => u.isHazardous).length,
|
||||
reeferQuantity: units.filter((u) => u.isReefer).length,
|
||||
returnQuantity: units.filter((u) => u.isReturn).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
|
||||
* per-container opt-ins decide whether THIS shipment actually uses it. A
|
||||
* container contract that allows hazardous but a booking where nobody ticked
|
||||
* the switch is not a hazardous booking, and must not fire the surcharge.
|
||||
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
|
||||
*/
|
||||
private resolveShipmentHandlingFlag(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
field: 'hazardousQuantity' | 'reeferQuantity',
|
||||
): boolean {
|
||||
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
|
||||
if (!gated) return false;
|
||||
if (contract.freightType !== 'CONTAINER') return true;
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) return Boolean(gated);
|
||||
return lines.some((l) => this.handlingCounts(l)[field] > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the booking's equipment return from the per-line return quantities
|
||||
* (container freight). The CONTRACT gates the service — like hazardous:
|
||||
@@ -1470,7 +1520,7 @@ export class ContractBookingService {
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
for (const line of lines) {
|
||||
const qty = Number(line.returnQuantity ?? 0);
|
||||
const qty = this.handlingCounts(line).returnQuantity;
|
||||
if (qty === 0) continue;
|
||||
if (contract.equipmentReturn !== 'WITH_RETURN') {
|
||||
throw new BadRequestException(
|
||||
@@ -1486,7 +1536,7 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
if (contract.equipmentReturn === 'WITH_RETURN') {
|
||||
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
|
||||
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
|
||||
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
|
||||
}
|
||||
return legacy;
|
||||
@@ -1524,9 +1574,10 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
const counts = this.handlingCounts(line);
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
contract.isReefer || counts.reeferQuantity > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
@@ -1540,12 +1591,10 @@ export class ContractBookingService {
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: counts.hazardousQuantity,
|
||||
reeferQuantity: counts.reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
|
||||
@@ -1564,6 +1613,8 @@ export class ContractBookingService {
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn:
|
||||
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
@@ -1664,8 +1715,8 @@ export class ContractBookingService {
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
@@ -1680,11 +1731,11 @@ export class ContractBookingService {
|
||||
containerTypeId: ct.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
|
||||
reeferQuantity: this.handlingCounts(line).reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
? this.handlingCounts(line).returnQuantity
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
|
||||
@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'This container ships back empty (equipment return).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export class CreateBookingContainerLineDto {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { deriveRateType } from './rate-type.util';
|
||||
|
||||
describe('deriveRateType — surcharge triggers', () => {
|
||||
// Every surcharge trigger must land on its own rateType. A trigger with no
|
||||
// mapping falls through to the base-freight branch and is silently stored as
|
||||
// CANCELLATION_FEE, which both mislabels the booking's rate snapshot and
|
||||
// hides the rate from contract pricing (which looks rateTypes up by name).
|
||||
it.each([
|
||||
['HAZARDOUS', 'HAZARD_SURCHARGE'],
|
||||
['REEFER', 'REEFER_SURCHARGE'],
|
||||
['WITH_RETURN', 'RETURN_SURCHARGE'],
|
||||
['OVERWEIGHT', 'OVERWEIGHT_PER_TON'],
|
||||
['SHIPPING_LINE', 'DOUBLE_HANDLING'],
|
||||
['CONSOLIDATION', 'LASHING'],
|
||||
['CANCELLATION', 'CANCELLATION_FEE'],
|
||||
['DEMURRAGE', 'DEMURRAGE'],
|
||||
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
|
||||
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
|
||||
] as const)('maps trigger %s to %s', (trigger, expected) => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not fall back to CANCELLATION_FEE for the empty-return service', () => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe(
|
||||
'CANCELLATION_FEE',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ export function deriveRateType(input: {
|
||||
return 'HAZARD_SURCHARGE';
|
||||
case 'REEFER':
|
||||
return 'REEFER_SURCHARGE';
|
||||
// Empty-container return service. Contract pricing looks this rateType up
|
||||
// by name, so without the mapping a WITH_RETURN rate fell through to the
|
||||
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
|
||||
// the contract, and mislabelled on the booking's snapshot.
|
||||
case 'WITH_RETURN':
|
||||
return 'RETURN_SURCHARGE';
|
||||
case 'OVERWEIGHT':
|
||||
return 'OVERWEIGHT_PER_TON';
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
/**
|
||||
* How many individual containers on this line opted into each handling
|
||||
* service. PER_CONTAINER surcharges bill these counts, not the line
|
||||
* quantity — 20 containers with 10 hazardous bill hazard on 10.
|
||||
*/
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
returnQuantity?: number;
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
@@ -270,6 +278,27 @@ export class RuleEngineService {
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
/**
|
||||
* Containers that opted into this trigger's handling service, summed
|
||||
* across lines. null when the trigger isn't per-container handling (or
|
||||
* no line carries a count) so the caller falls back to the full count.
|
||||
*/
|
||||
const optedInCount = (trigger: string | null): number | null => {
|
||||
const field =
|
||||
trigger === 'HAZARDOUS'
|
||||
? 'hazardousQuantity'
|
||||
: trigger === 'REEFER'
|
||||
? 'reeferQuantity'
|
||||
: trigger === 'WITH_RETURN'
|
||||
? 'returnQuantity'
|
||||
: null;
|
||||
if (!field) return null;
|
||||
const total = input.containers.reduce(
|
||||
(sum, c) => sum + Number(c[field] ?? 0),
|
||||
0,
|
||||
);
|
||||
return total > 0 ? total : null;
|
||||
};
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount: number;
|
||||
@@ -285,7 +314,11 @@ export class RuleEngineService {
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_CONTAINER':
|
||||
triggerValue = containerCount;
|
||||
// Handling surcharges bill only the containers that opted in, not the
|
||||
// whole line — 20 containers with 10 hazardous bill hazard on 10.
|
||||
// Legacy bookings carry no per-container counts (all 0) while their
|
||||
// booking-level flag is set, so fall back to the full count there.
|
||||
triggerValue = optedInCount(rate.trigger) ?? containerCount;
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_WAGON':
|
||||
|
||||
@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
// assertion below that says "not full" proves those axes are ignored.
|
||||
const scheduleId = 'schedule-built';
|
||||
|
||||
const reservedBooking = (id: string) =>
|
||||
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
|
||||
({
|
||||
id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||||
bookingContainers: [],
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
originYardId: leg?.origin ?? 'yard-a',
|
||||
destinationYardId: leg?.dest ?? 'yard-b',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const buildService = (opts: {
|
||||
physicalWagons: number;
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null,
|
||||
routeId: opts.routeStops ? 'route-1' : null,
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
},
|
||||
};
|
||||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||||
const milestoneRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
|
||||
),
|
||||
};
|
||||
const genericRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) =>
|
||||
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
|
||||
),
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new BookingBatchService(
|
||||
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -3607,11 +3607,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
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();
|
||||
@@ -3620,6 +3628,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gross weight / shortest length one more wagon could add: the
|
||||
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
|
||||
|
||||
@@ -126,15 +126,15 @@ export class IntercityService {
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
...this.mapBooking(booking),
|
||||
need: capacity?.needFor(booking) ?? null,
|
||||
})),
|
||||
accepted: accepted.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return { ...this.mapBooking(booking, need), need };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,7 +356,12 @@ export class IntercityService {
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
/**
|
||||
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
|
||||
* spent in. Prefer it, so the row's weight sits on the same axis as the
|
||||
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
|
||||
*/
|
||||
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
@@ -372,7 +377,7 @@ export class IntercityService {
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
'Unknown destination',
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
|
||||
freightType: string | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||||
grossWeightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus: string | null;
|
||||
wagonsRequired: number;
|
||||
@@ -3866,11 +3868,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (totalWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
@@ -3897,7 +3906,7 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
totalWeightTons ||
|
||||
grossWeightTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
) {
|
||||
@@ -3918,7 +3927,7 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
totalWeightTons &&
|
||||
grossWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
)
|
||||
@@ -3940,6 +3949,9 @@ export class TrainSchedulingService {
|
||||
summary: {
|
||||
totalBookings: fittingBookings.length,
|
||||
totalWeightTons,
|
||||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||||
grossWeightTons,
|
||||
totalTareTons,
|
||||
// Human-readable wagon type(s) of the plan — mixed consists list all.
|
||||
wagonType: plannedTypeCodes.join('/') || 'NONE',
|
||||
wagonsNeeded: wagonPlan.length,
|
||||
@@ -7036,6 +7048,15 @@ export class TrainSchedulingService {
|
||||
shortfall: 0,
|
||||
}));
|
||||
|
||||
// Gross weight needs the scheduling graph (containers, cargo type, wagon
|
||||
// types) that the trimmed select above deliberately skips.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
const fullById = new Map(
|
||||
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
|
||||
(b) => [b.id, b],
|
||||
),
|
||||
);
|
||||
|
||||
const bookings = await Promise.all(
|
||||
unassigned.map(async (b) => {
|
||||
const assignability = await this.previewUnassignedBookingAssignability(
|
||||
@@ -7050,6 +7071,11 @@ export class TrainSchedulingService {
|
||||
freightType: b.freightType ?? null,
|
||||
priorityScore: b.priorityScore ?? 0,
|
||||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||||
grossWeightTons: this.grossBookingWeightTons(
|
||||
(fullById.get(b.id) ?? b) as Booking,
|
||||
tareDims,
|
||||
),
|
||||
status: b.status ?? null,
|
||||
schedulingStatus: b.schedulingStatus ?? null,
|
||||
...assignability,
|
||||
|
||||
Reference in New Issue
Block a user