From 0dead281ce559a111c800a1057f8eb87a96ffe59 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 18 Jul 2026 19:20:45 +0000 Subject: [PATCH] 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. --- ...370000000000-AddContainerUnitReturnFlag.ts | 28 ++ .../bookings/booking-pricing.service.ts | 4 + .../entities/booking-container-unit.entity.ts | 4 + .../contracts/contract-booking.service.ts | 83 ++++-- .../dto/create-booking-under-contract.dto.ts | 9 + .../entities/rate-type.util.spec.ts | 28 ++ .../rule-engine/entities/rate-type.util.ts | 6 + .../rule-engine/rule-engine.service.ts | 35 ++- .../booking-batch.service.spec.ts | 40 ++- .../train-scheduling/booking-batch.service.ts | 39 ++- .../train-scheduling/intercity.service.ts | 19 +- .../train-scheduling.service.ts | 34 ++- .../contracts/GlCreateBookingForm.tsx | 159 +++++++----- .../trainScheduling/ScheduleWarningsAlert.tsx | 6 +- .../ScheduleWorkspacePanel.tsx | 5 +- .../TrainCompositionDiagram.tsx | 17 +- .../trainScheduling/WagonPlanGrid.tsx | 24 +- .../compositionEditor/BookingDetailModal.tsx | 2 +- .../InteractiveTrainConsist.tsx | 7 +- .../compositionEditor/RemoveBookingModal.tsx | 4 +- .../compositionEditor/TrainConsistView.tsx | 8 +- .../compositionEditor/TrainStatsBar.tsx | 2 +- .../UnassignedBookingsPanel.tsx | 4 +- .../compositionEditor/WagonCard.tsx | 8 +- .../backoffice/src/types/trainScheduling.ts | 6 + .../src/pages/contracts/NewShipmentPage.tsx | 241 +++++++++--------- .../contracts/new-shipment-form/schema.ts | 5 + packages/types/src/freight/contracts.ts | 8 + 28 files changed, 585 insertions(+), 250 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d3fe02d5f..cbb630794 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8ef819eaf..97dc7767f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..3b5a30ca0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts new file mode 100644 index 000000000..480b2c484 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 09f35c458..a5b5bfc30 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 03be1b3d0..d1715d9fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c19140d8a..fc97f772b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index c06f0ee81..5837e4cd2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { + // 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 { + 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..2f539bb43 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index eb3fd7201..38bc21156 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5633745aa..5225dc6b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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 + ).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, ) => - 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 && ( - - patchLine(lineIdx, { - hazardousQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contract.isReefer && ( - - patchLine(lineIdx, { - reeferQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contractWithReturn && ( - - patchLine(lineIdx, { - returnQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} Per-container details + {handlingColumns.length > 0 ? ( + + 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("")} + . + + ) : null} {line.units.map((unit, unitIdx) => ( @@ -1296,6 +1299,22 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> + {handlingColumns.map((col) => ( + + patchUnit(lineIdx, unitIdx, { + [col.key]: e.currentTarget.checked, + }) + } + label={unitIdx === 0 ? col.label : undefined} + labelPosition="right" + size="sm" + mt={unitIdx === 0 ? 26 : 6} + /> + ))} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx index 25bf2480f..d84d4e8be 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx @@ -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 ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 867a0f895..c7fc49b18 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 2805ba60a..af8917e00 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -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 }) { /> - {wagon.assignedWeightTons}/{wagon.capacityTons}T + {grossTons}/{maxGrossTons}T ) : ( @@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {!wagon.isEmpty ? ( - {wagon.assignedWeightTons}T + {grossTons}T ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx index 086f78ddc..e5b63ea24 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx @@ -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({ {isBulk ? ( - Load: {totalAssigned} / {totalCapacity}T + Gross: {totalAssigned} / {totalCapacity}T ) : null} @@ -90,8 +101,9 @@ export function WagonPlanGrid({ {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({ {label === "BULK" ? ( - {alloc.allocatedWeightTons}T + {alloc.allocatedWeightTons}T cargo ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx index 00df4700c..9e7062ad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -132,7 +132,7 @@ export const BookingDetailModal = ({ /> } - label="Weight" + label="Gross weight" value={ {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 8af9a6e34..8ac374c1a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -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)`; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 4d2b1369c..80cbeaad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -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 ( @@ -40,7 +42,7 @@ export const RemoveBookingModal = ({ - Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + Gross weight: {grossTons.toFixed(2)} T Wagon Slot: #{wagon.sequenceNo} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 6ece98ef2..e5b12fe1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -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 ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx index 8ec1c7f4e..3403258ef 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -98,7 +98,7 @@ export const TrainStatsBar = ({ } - label="Weight" + label="Gross weight" pct={weightPct} current={weightUsed.toFixed(1)} max={weightMax?.toFixed(1) ?? "∞"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index e5225f8dd..3af00fd88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index f735e6474..915cfa629 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index e8aaf71ec..40337a4cd 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -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; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index ea83c8163..7bdb06f84 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -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: , + color: "#C0392B", }, - ) => ( - ( - 0} - onChange={(on) => field.onChange(on ? "1" : "0")} - > -
- - {fieldState.error?.message ? ( - - {fieldState.error.message} - - ) : null} -
-
- )} - /> - ); + isReefer && { + key: "isReefer" as const, + label: "Refrigerated", + icon: , + color: "#2E5B96", + }, + withReturnService && { + key: "isReturn" as const, + label: "With return", + icon: , + color: "#0A6F4D", + }, + ].filter(Boolean) as Array<{ + key: "isHazardous" | "isReefer" | "isReturn"; + label: string; + icon: ReactNode; + color: string; + }>; return ( - {/* 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 && ( - <> - Cargo handling -
- {isHazardous && - handlingToggle("hazardousQuantity", { - icon: , - 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: , - 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: , - 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", - })} -
- - )} - Per-container details + {handlingColumns.length > 0 && quantity > 0 ? ( + + Tick the services each individual container needs — charges apply only + to the containers you tick. + + ) : null} {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( @@ -1739,6 +1721,37 @@ function ContainerLineEditor({ /> )} /> + {handlingColumns.map((col) => ( + ( + + toggleUnitHandling(u, col.key, e.currentTarget.checked) + } + label={ + u === 0 ? ( + + + {col.icon} + + + {col.label} + + + ) : undefined + } + labelPosition="right" + size="sm" + mt={u === 0 ? 26 : 6} + /> + )} + /> + ))} ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 8610753c9..9c7efb8e3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -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({ diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 9aa36374a..362d3c6ef 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -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; /**