mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add per-container handling options for hazardous, reefer, and return services
- Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options. - Updated emptyUnit function to initialize these new fields. - Modified GlCreateBookingForm to handle and display these options for each container. - Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options. - Updated the schema for container units and booking container lines to include handling options. - Added migration to support the new return flag in the database. - Enhanced various components to reflect gross weight calculations, ensuring consistency across the application.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-container handling opt-in: each physical container can now be marked
|
||||
* hazardous / reefer / with-return individually, next to its VGM. The hazardous
|
||||
* and reefer flags already existed on the unit row; only the return leg was
|
||||
* missing, so a booking of 20 containers with 10 returning empty can bill the
|
||||
* WITH_RETURN surcharge on 10 instead of all 20.
|
||||
*
|
||||
* Backfill: existing rows keep false. The line-level counts
|
||||
* (booking_container.return_quantity etc.) stay authoritative for bookings made
|
||||
* before this change — the rule engine falls back to them when no unit is flagged.
|
||||
*/
|
||||
export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface {
|
||||
name = 'AddContainerUnitReturnFlag2370000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -64,15 +64,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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,7 +294,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,
|
||||
@@ -310,7 +315,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,
|
||||
|
||||
@@ -128,6 +128,10 @@ interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: string;
|
||||
/** Handling is per physical container; the line counts roll these up. */
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors the portal shipment form's container line: line-level quantity +
|
||||
@@ -150,7 +154,14 @@ interface BulkDraft {
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
return {
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isReturn: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyLine(size: string): ContainerLineDraft {
|
||||
@@ -285,6 +296,20 @@ export default function GlCreateBookingForm() {
|
||||
// Legacy contracts (no equipment return chosen at creation) keep the old
|
||||
// booking-level toggle.
|
||||
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
|
||||
/**
|
||||
* Handling switches offered on each container row — only the services this
|
||||
* contract was created with, since the server rejects the others.
|
||||
*/
|
||||
const handlingColumns = (
|
||||
[
|
||||
contract?.isHazardous && { key: "isHazardous", label: "Hazardous" },
|
||||
contract?.isReefer && { key: "isReefer", label: "Refrigerated" },
|
||||
contractWithReturn && { key: "isReturn", label: "With return" },
|
||||
] as Array<false | undefined | { key: keyof UnitDraft; label: string }>
|
||||
).filter(Boolean) as Array<{
|
||||
key: "isHazardous" | "isReefer" | "isReturn";
|
||||
label: string;
|
||||
}>;
|
||||
// Intercity shipments ride a passing import/export train staff pick at
|
||||
// finalize time — no shipment day is chosen and no window gate applies.
|
||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||
@@ -488,6 +513,18 @@ export default function GlCreateBookingForm() {
|
||||
enabled: cargoQuery !== null && !isIntercity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Line handling totals are a roll-up of the per-container switches — the
|
||||
* count is however many containers ticked each service. Recomputed on every
|
||||
* unit change so the price estimate and payload follow the switches.
|
||||
*/
|
||||
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
|
||||
...line,
|
||||
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
|
||||
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
|
||||
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
|
||||
});
|
||||
|
||||
// Keep the units array length in sync with the entered quantity.
|
||||
const syncUnits = (lineIdx: number, qty: number) => {
|
||||
setContainerLines((prev) =>
|
||||
@@ -496,7 +533,7 @@ export default function GlCreateBookingForm() {
|
||||
const next = [...line.units];
|
||||
while (next.length < qty) next.push(emptyUnit());
|
||||
next.length = Math.max(0, qty);
|
||||
return { ...line, units: next };
|
||||
return withDerivedCounts({ ...line, units: next });
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -511,11 +548,16 @@ export default function GlCreateBookingForm() {
|
||||
unitIdx: number,
|
||||
patch: Partial<UnitDraft>,
|
||||
) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.map((u, i) =>
|
||||
i === unitIdx ? { ...u, ...patch } : u,
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) =>
|
||||
i === lineIdx
|
||||
? withDerivedCounts({
|
||||
...l,
|
||||
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
|
||||
})
|
||||
: l,
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
// Same client-side validation as the customer portal shipment form
|
||||
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
||||
@@ -560,10 +602,15 @@ export default function GlCreateBookingForm() {
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||
// The spreadsheet marks handling per row — carry it onto the
|
||||
// container it belongs to rather than collapsing it to a line count.
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: String(r.vgmTons),
|
||||
isHazardous: Boolean(r.hazardous),
|
||||
isReefer: Boolean(r.reefer),
|
||||
isReturn: Boolean(r.withReturn),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -742,6 +789,11 @@ export default function GlCreateBookingForm() {
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
// Per-container handling — the server rolls these into the line
|
||||
// counts and bills each surcharge on the ticked containers only.
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
@@ -1175,73 +1227,24 @@ export default function GlCreateBookingForm() {
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{contract.isHazardous && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.hazardousQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
hazardousQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
{contract.isReefer && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.reeferQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
reeferQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
{contractWithReturn && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="With return qty"
|
||||
description="Containers EDR returns empty"
|
||||
min={0}
|
||||
value={line.returnQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.returnQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
returnQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
{handlingColumns.length > 0 ? (
|
||||
<Text fz={11} c="dimmed" mt={4}>
|
||||
Tick the services each individual container needs —
|
||||
charges apply only to the containers ticked
|
||||
{handlingColumns
|
||||
.map((col) => {
|
||||
const count = line.units.filter(
|
||||
(u) => u[col.key],
|
||||
).length;
|
||||
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
|
||||
})
|
||||
.join("")}
|
||||
.
|
||||
</Text>
|
||||
) : null}
|
||||
<Stack gap={10} mt={8}>
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Group key={unitIdx} gap={10} grow align="flex-start">
|
||||
@@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() {
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{handlingColumns.map((col) => (
|
||||
<Switch
|
||||
key={col.key}
|
||||
checked={Boolean(unit[col.key])}
|
||||
aria-label={`${col.label} — container ${unitIdx + 1}`}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
[col.key]: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
label={unitIdx === 0 ? col.label : undefined}
|
||||
labelPosition="right"
|
||||
size="sm"
|
||||
mt={unitIdx === 0 ? 26 : 6}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -40,17 +40,21 @@ export function PreviewSummary({
|
||||
summary?: {
|
||||
totalBookings: number;
|
||||
totalWeightTons: number;
|
||||
grossWeightTons?: number;
|
||||
totalTareTons?: number;
|
||||
wagonType: string;
|
||||
wagonsNeeded: number;
|
||||
totalLengthMeters: number;
|
||||
};
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
// GROSS — the axis every train limit is spent against.
|
||||
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
|
||||
const stats = [
|
||||
{ label: "Bookings", value: String(summary.totalBookings) },
|
||||
{ label: "Wagons", value: String(summary.wagonsNeeded) },
|
||||
{ label: "Wagon type", value: summary.wagonType },
|
||||
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
|
||||
{ label: "Gross weight", value: `${gross}T` },
|
||||
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
|
||||
/**
|
||||
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
|
||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
||||
* consist can only pull as hard as its weakest engine. Note: the API also adds
|
||||
* the consist tare to the used weight when it checks this cap; tare isn't
|
||||
* available client-side, so this meter compares cargo-only load against pull.
|
||||
* consist can only pull as hard as its weakest engine. Both sides of this meter
|
||||
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
|
||||
*/
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
const set = schedule.trainSet;
|
||||
|
||||
@@ -46,6 +46,8 @@ type NormalizedWagon = {
|
||||
|
||||
const CAR_WIDTH = 150; // car body + coupler footprint
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
|
||||
const allocations = w.allocations ?? [];
|
||||
const firstLoad = (
|
||||
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
|
||||
];
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
|
||||
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
|
||||
const utilization =
|
||||
wagon.capacityTons > 0
|
||||
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
|
||||
: 0;
|
||||
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
|
||||
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
|
||||
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
|
||||
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
||||
}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
/>
|
||||
</Box>
|
||||
<Text size="9px" c="dimmed" ta="center" fw={600}>
|
||||
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
||||
{grossTons}/{maxGrossTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Text>
|
||||
{!wagon.isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{wagon.assignedWeightTons}T
|
||||
{grossTons}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons?: number | null;
|
||||
slotLoadType?: string;
|
||||
wagonType?: { code: string } | null;
|
||||
wagonTypeCode?: string;
|
||||
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
}>;
|
||||
};
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
|
||||
const normalized = loadType?.toUpperCase() ?? "";
|
||||
if (normalized.includes("BULK")) return "orange";
|
||||
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
|
||||
);
|
||||
}
|
||||
|
||||
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
|
||||
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const totalTare = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
const totalCapacity = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
|
||||
);
|
||||
const totalAssigned = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
|
||||
);
|
||||
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
|
||||
|
||||
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
|
||||
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
|
||||
</Text>
|
||||
{isBulk ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
|
||||
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
|
||||
{wagonPlan.map((wagon) => {
|
||||
const seq = wagon.sequenceNo;
|
||||
const capacity = wagon.capacityTons;
|
||||
const assigned = wagon.assignedWeightTons;
|
||||
const tare = Number(wagon.tareWeightTons) || 0;
|
||||
const capacity = round1(wagon.capacityTons + tare);
|
||||
const assigned = round1(wagon.assignedWeightTons + tare);
|
||||
const allocations = wagon.allocations ?? [];
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const label = slotLabel(wagon, freightType);
|
||||
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
|
||||
</Text>
|
||||
{label === "BULK" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{alloc.allocatedWeightTons}T
|
||||
{alloc.allocatedWeightTons}T cargo
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
label="Gross weight"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
|
||||
|
||||
@@ -161,8 +161,11 @@ function WagonCar({
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
|
||||
const capacity = wagon.capacityTons ?? 0;
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const assigned =
|
||||
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
|
||||
const capacity = (wagon.capacityTons ?? 0) + tare;
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
// GROSS: allocated cargo + the tare of the wagon it sits on.
|
||||
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
|
||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
|
||||
@@ -93,10 +93,14 @@ export const TrainConsistView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const weightUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
|
||||
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
|
||||
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
|
||||
const cargoUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
||||
0,
|
||||
);
|
||||
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
|
||||
const weightUsed = cargoUsed + tareUsed;
|
||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
|
||||
<StatTile
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
label="Gross weight"
|
||||
pct={weightPct}
|
||||
current={weightUsed.toFixed(1)}
|
||||
max={weightMax?.toFixed(1) ?? "∞"}
|
||||
|
||||
@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
|
||||
|
||||
{bookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
// GROSS (cargo + wagon tare) so this badge shares the axis every other
|
||||
// weight on the page uses — cargo-only here read ~25% light.
|
||||
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
|
||||
const fits = booking.canAssign;
|
||||
const blockReason = booking.blockReason;
|
||||
|
||||
|
||||
@@ -36,8 +36,12 @@ export const WagonCard = ({
|
||||
const hasAllocations = Boolean(allocation);
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
|
||||
const weightUsed = allocation?.allocatedWeightTons ?? 0;
|
||||
const weightMax = wagon.capacityTons ?? 0;
|
||||
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
|
||||
// gross (rated payload + tare). Keeps the wagon axis identical to the train
|
||||
// axis in TrainStatsBar.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
|
||||
const weightMax = (wagon.capacityTons ?? 0) + tare;
|
||||
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
|
||||
|
||||
const wagonType = wagon.wagonType?.code || "UNKNOWN";
|
||||
|
||||
@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
summary: {
|
||||
totalBookings: number;
|
||||
/** Cargo VGM only — display gross instead. */
|
||||
totalWeightTons: number;
|
||||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||||
grossWeightTons: number;
|
||||
totalTareTons: number;
|
||||
wagonType: string;
|
||||
wagonsNeeded: number;
|
||||
totalLengthMeters: number;
|
||||
@@ -845,6 +849,8 @@ export interface CompositionUnassignedBooking {
|
||||
freightType: FreightType | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||||
grossWeightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus: SchedulingStatus | null;
|
||||
wagonsRequired: number;
|
||||
|
||||
@@ -59,8 +59,6 @@ import {
|
||||
StepCard,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
ToggleRow,
|
||||
UnitCountToggles,
|
||||
fieldStyles,
|
||||
} from "./new-contract-form/shared";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
@@ -362,6 +360,11 @@ function NewShipmentBookingForm({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber || undefined,
|
||||
vgmTons: Number(u.vgmTons),
|
||||
// Per-container handling — the server rolls these up into the
|
||||
// line counts and bills each surcharge on the ticked containers.
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
@@ -1133,7 +1136,7 @@ function CargoStep({
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
units: [emptyUnit()],
|
||||
})),
|
||||
{ shouldValidate: false },
|
||||
);
|
||||
@@ -1177,7 +1180,7 @@ function CargoStep({
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
units: [emptyUnit()],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1187,10 +1190,15 @@ function CargoStep({
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||
// The spreadsheet already marks handling per row — carry it onto the
|
||||
// container it belongs to rather than collapsing it to a line count.
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: r.vgmTons,
|
||||
isHazardous: Boolean(r.hazardous),
|
||||
isReefer: Boolean(r.reefer),
|
||||
isReturn: Boolean(r.withReturn),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -1511,6 +1519,16 @@ function NotesSection({ form }: { form: ShipmentForm }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A blank container row — handling switches start off. */
|
||||
const emptyUnit = () => ({
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isReturn: false,
|
||||
});
|
||||
|
||||
function ContainerLineEditor({
|
||||
form,
|
||||
index,
|
||||
@@ -1536,76 +1554,81 @@ function ContainerLineEditor({
|
||||
const current = form.getValues(`containers.${index}.units`) ?? [];
|
||||
const next = [...current];
|
||||
while (next.length < qty)
|
||||
next.push({ containerNumber: "", sealNumber: "", vgmTons: "" });
|
||||
next.push({
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isReturn: false,
|
||||
});
|
||||
next.length = Math.max(0, qty);
|
||||
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
||||
syncHandlingCounts(next);
|
||||
};
|
||||
|
||||
// Lowering the line quantity must pull every cargo-handling count back within
|
||||
// it, or a stale count silently exceeds the line and fails validation on a
|
||||
// field the customer can no longer see a cause for.
|
||||
const clampHandlingCounts = (qty: number) => {
|
||||
(["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
|
||||
(key) => {
|
||||
const path = `containers.${index}.${key}` as const;
|
||||
const current = Number(form.getValues(path) || 0);
|
||||
if (current > qty)
|
||||
form.setValue(path, String(Math.max(0, qty)), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
/**
|
||||
* Line totals are a roll-up of the per-container switches — the count is
|
||||
* however many containers ticked each service. Kept in form state so the
|
||||
* price estimate and the submitted payload stay in step with the switches.
|
||||
*/
|
||||
const syncHandlingCounts = (
|
||||
units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>,
|
||||
) => {
|
||||
const set = (
|
||||
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
|
||||
count: number,
|
||||
) =>
|
||||
form.setValue(`containers.${index}.${key}`, String(count), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
set("hazardousQuantity", units.filter((u) => u.isHazardous).length);
|
||||
set("reeferQuantity", units.filter((u) => u.isReefer).length);
|
||||
set("returnQuantity", units.filter((u) => u.isReturn).length);
|
||||
};
|
||||
|
||||
/** Switch state is derived from the count — a line is hazardous iff qty > 0. */
|
||||
const handlingToggle = (
|
||||
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
|
||||
opts: {
|
||||
icon: ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pickLabel: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeColor: string;
|
||||
/** Flip one container's handling switch, then re-roll the line totals. */
|
||||
const toggleUnitHandling = (
|
||||
unitIndex: number,
|
||||
key: "isHazardous" | "isReefer" | "isReturn",
|
||||
on: boolean,
|
||||
) => {
|
||||
form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []);
|
||||
};
|
||||
|
||||
/**
|
||||
* The handling columns offered on each container row — only the services this
|
||||
* contract was created with, since the server rejects quantities for the others.
|
||||
*/
|
||||
const handlingColumns = [
|
||||
isHazardous && {
|
||||
key: "isHazardous" as const,
|
||||
label: "Hazardous",
|
||||
icon: <Flame size={14} />,
|
||||
color: "#C0392B",
|
||||
},
|
||||
) => (
|
||||
<Controller
|
||||
name={`containers.${index}.${key}`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<ToggleRow
|
||||
icon={opts.icon}
|
||||
iconBg={opts.iconBg}
|
||||
iconColor={opts.iconColor}
|
||||
title={opts.title}
|
||||
description={opts.description}
|
||||
checked={Number(field.value || 0) > 0}
|
||||
onChange={(on) => field.onChange(on ? "1" : "0")}
|
||||
>
|
||||
<div>
|
||||
<UnitCountToggles
|
||||
total={quantity}
|
||||
value={field.value ?? "0"}
|
||||
onChange={field.onChange}
|
||||
label={opts.pickLabel}
|
||||
activeBg={opts.activeBg}
|
||||
activeBorder={opts.activeBorder}
|
||||
activeColor={opts.activeColor}
|
||||
/>
|
||||
{fieldState.error?.message ? (
|
||||
<Text fz={11} c="red.7" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</ToggleRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
isReefer && {
|
||||
key: "isReefer" as const,
|
||||
label: "Refrigerated",
|
||||
icon: <Snowflake size={14} />,
|
||||
color: "#2E5B96",
|
||||
},
|
||||
withReturnService && {
|
||||
key: "isReturn" as const,
|
||||
label: "With return",
|
||||
icon: <Repeat size={14} />,
|
||||
color: "#0A6F4D",
|
||||
},
|
||||
].filter(Boolean) as Array<{
|
||||
key: "isHazardous" | "isReefer" | "isReturn";
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -1640,54 +1663,13 @@ function ContainerLineEditor({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Cargo handling — only the services this contract was created with are
|
||||
offered, since the server rejects quantities for the others. Each
|
||||
switch reveals a bounded picker: tap the containers it applies to. */}
|
||||
{(isHazardous || isReefer || withReturnService) && quantity > 0 && (
|
||||
<>
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
|
||||
{isHazardous &&
|
||||
handlingToggle("hazardousQuantity", {
|
||||
icon: <Flame size={18} />,
|
||||
iconBg: "#FBEAE7",
|
||||
iconColor: "#C0392B",
|
||||
title: "Hazardous",
|
||||
description: "Some of these containers carry hazardous cargo.",
|
||||
pickLabel: "Tap the hazardous containers",
|
||||
activeBg: "#FBEAE7",
|
||||
activeBorder: "#E4A69B",
|
||||
activeColor: "#C0392B",
|
||||
})}
|
||||
{isReefer &&
|
||||
handlingToggle("reeferQuantity", {
|
||||
icon: <Snowflake size={18} />,
|
||||
iconBg: "#E9F0F8",
|
||||
iconColor: "#2E5B96",
|
||||
title: "Refrigerated",
|
||||
description: "Some of these containers need reefer transport.",
|
||||
pickLabel: "Tap the refrigerated containers",
|
||||
activeBg: "#E9F0F8",
|
||||
activeBorder: "#A9C2E0",
|
||||
activeColor: "#2E5B96",
|
||||
})}
|
||||
{withReturnService &&
|
||||
handlingToggle("returnQuantity", {
|
||||
icon: <Repeat size={18} />,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A6F4D",
|
||||
title: "With return",
|
||||
description: "Some of these containers come back to EDR empty.",
|
||||
pickLabel: "Tap the containers EDR returns",
|
||||
activeBg: "#ECF6F1",
|
||||
activeBorder: "#A9D6C2",
|
||||
activeColor: "#0A6F4D",
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
{handlingColumns.length > 0 && quantity > 0 ? (
|
||||
<Text fz={11} c="#5B6B7B" mt={4}>
|
||||
Tick the services each individual container needs — charges apply only
|
||||
to the containers you tick.
|
||||
</Text>
|
||||
) : null}
|
||||
<Stack gap={10} mt={8}>
|
||||
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
|
||||
<Group key={u} gap={10} grow align="flex-start">
|
||||
@@ -1739,6 +1721,37 @@ function ContainerLineEditor({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{handlingColumns.map((col) => (
|
||||
<Controller
|
||||
key={col.key}
|
||||
name={`containers.${index}.units.${u}.${col.key}`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={Boolean(field.value)}
|
||||
aria-label={`${col.label} — container ${u + 1}`}
|
||||
onChange={(e) =>
|
||||
toggleUnitHandling(u, col.key, e.currentTarget.checked)
|
||||
}
|
||||
label={
|
||||
u === 0 ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<span style={{ color: col.color, display: "flex" }}>
|
||||
{col.icon}
|
||||
</span>
|
||||
<Text fz={12} fw={600} c="#10202F">
|
||||
{col.label}
|
||||
</Text>
|
||||
</Group>
|
||||
) : undefined
|
||||
}
|
||||
labelPosition="right"
|
||||
size="sm"
|
||||
mt={u === 0 ? 26 : 6}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -48,6 +48,11 @@ const containerUnitSchema = z.object({
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "VGM is required.")
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."),
|
||||
// Handling is per physical container, recorded next to its VGM. The line
|
||||
// totals below are derived from these.
|
||||
isHazardous: z.boolean().default(false),
|
||||
isReefer: z.boolean().default(false),
|
||||
isReturn: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const containerLineSchema = z.object({
|
||||
|
||||
@@ -729,14 +729,22 @@ export interface CreateContainerUnitDto {
|
||||
containerNumber: string;
|
||||
sealNumber?: string;
|
||||
vgmTons: number;
|
||||
/** Per-container handling opt-ins, entered alongside this container's VGM. */
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
/** This container ships back empty (equipment return). */
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBookingContainerLineDto {
|
||||
/** "20ft" | "40ft" — must be in the contract's cargo scope. */
|
||||
containerSize: string;
|
||||
quantity: number;
|
||||
/**
|
||||
* Line totals, derived from the per-unit switches above. The API recomputes
|
||||
* them from `units` whenever any unit carries a flag, so they are only
|
||||
* authoritative for callers that don't send per-unit flags.
|
||||
*/
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user