diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 6757a6e21..0d724a040 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1133,7 +1133,12 @@ describe('TrainSchedulingService', () => { let slotB: Record; let allocsByWagon: Record>>; let allocRepo: { find: jest.Mock; update: jest.Mock }; - let slotRepo: { update: jest.Mock }; + let slotRepo: { + update: jest.Mock; + create: jest.Mock; + save: jest.Mock; + createQueryBuilder: jest.Mock; + }; let wagonRepo: { findOne: jest.Mock }; const makeSchedule = (over: Record = {}) => ({ @@ -1147,6 +1152,7 @@ describe('TrainSchedulingService', () => { beforeEach(() => { slotA = { id: 'wA', + trainSetId: 'ts-1', sequenceNo: 1, capacityTons: 61, lengthMeters: 14, @@ -1158,6 +1164,7 @@ describe('TrainSchedulingService', () => { }; slotB = { id: 'wB', + trainSetId: 'ts-1', sequenceNo: 2, capacityTons: 61, lengthMeters: 14, @@ -1186,7 +1193,18 @@ describe('TrainSchedulingService', () => { ), update: jest.fn().mockResolvedValue(undefined), }; - slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + slotRepo = { + update: jest.fn().mockResolvedValue(undefined), + create: jest.fn((row: Record) => row), + save: jest.fn((row: Record) => + Promise.resolve({ id: 'slot-new', ...row }), + ), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }), + })), + }; wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; dataSource.getRepository.mockImplementation((entity: unknown) => { if (entity === WagonBookingAllocation) return allocRepo; @@ -1245,7 +1263,7 @@ describe('TrainSchedulingService', () => { }); }); - it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + it('moves the load onto an empty consist-only wagon without renaming wagons', async () => { wagonRepo.findOne.mockResolvedValue({ id: 'phys-9', wagonTypeId: 'wt-1', @@ -1258,14 +1276,57 @@ describe('TrainSchedulingService', () => { expect(wagonRepo.findOne).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), ); - // Repin: wagon identity moves onto the slot; allocations stay put. + // A slot is created ON the target wagon, carrying the source's load + // fields. sequence_no appends past the existing max so it clears the + // (train_set_id, sequence_no) unique index. + expect(slotRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + trainSetId: 'ts-1', + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + sequenceNo: 3, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + }), + ); + // The whole load crosses onto that new slot… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' }); + // …and the source wagon stays itself, just empty. expect(slotRepo.update).toHaveBeenCalledWith('wA', { - physicalWagonId: 'phys-9', - wagonTypeId: 'wt-1', - capacityTons: 70, - lengthMeters: 14, + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, }); - expect(allocRepo.update).not.toHaveBeenCalled(); + // The bug this replaced: the source slot must NOT be repinned to another + // physical wagon — that reorders the train instead of moving the load. + expect(slotRepo.update).not.toHaveBeenCalledWith( + 'wA', + expect.objectContaining({ physicalWagonId: expect.anything() }), + ); + }); + + it('reuses the existing slot when the target wagon is addressed by wagon id', async () => { + // wB is already pinned to physical wagon phys-B. Addressing that wagon + // directly must land in wB, not mint a second slot on the same wagon. + (slotB as Record).physicalWagonId = 'phys-B'; + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-B', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-B', + wagonType: containerType, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' }); + + expect(slotRepo.save).not.toHaveBeenCalled(); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); }); it('rejects a bulk load onto a wagon whose type only supports containers', async () => { 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 fe5bb3a8e..15a542d31 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 @@ -7449,8 +7449,8 @@ export class TrainSchedulingService { // Target: a slot of this train set, or an empty consist-only wagon of the // built train (physical wagon with no slot row yet). - const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; - const consistWagon = targetSlot + const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const wagonForTarget = slotById ? null : schedule.trainSet?.trainId ? await this.dataSource.getRepository(Wagon).findOne({ @@ -7458,10 +7458,22 @@ export class TrainSchedulingService { relations: { wagonType: true }, }) : null; - if (!targetSlot && !consistWagon) { + if (!slotById && !wagonForTarget) { throw new NotFoundException('Target wagon is not part of this schedule'); } + // A physical wagon holds at most one slot. When the caller addressed the + // wagon directly but a slot is already pinned to it, move into that slot + // rather than minting a second one on the same wagon. + const targetSlot = + slotById ?? + (wagonForTarget + ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + : null); + const consistWagon = targetSlot ? null : wagonForTarget; const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + if (targetSlot && targetSlot.id === source.id) { + return this.getTrainScheduleById(scheduleId); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), @@ -7525,19 +7537,6 @@ export class TrainSchedulingService { const slotRepo = manager.getRepository(TrainSetWagon); const allocs = manager.getRepository(WagonBookingAllocation); - // Empty consist wagon: repin the loaded slot onto that physical wagon. - // Allocations and load fields stay put; only the wagon identity changes. - if (consistWagon) { - await slotRepo.update(source.id, { - physicalWagonId: consistWagon.id, - wagonTypeId: consistWagon.wagonTypeId, - capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), - lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), - }); - return; - } - - const target = targetSlot as TrainSetWagon; // Load-coupled slot fields travel with the load; wagon identity stays. const loadFieldsOf = (slot: TrainSetWagon) => ({ assignedWeightTons: slot.assignedWeightTons, @@ -7552,6 +7551,47 @@ export class TrainSchedulingService { alightYardId: null, }; const sourceLoadFields = loadFieldsOf(source); + + // Empty consist wagon with no slot row yet: give it one, then move the + // load into it. Repinning the SOURCE slot onto that wagon would have been + // fewer writes, but it renames the wagons instead of moving the load — + // the loaded slot becomes wagon B and B's identity pops out as an empty + // wagon where A used to be. Staff read that as the train re-ordering + // itself. A wagon must never change place because a container moved. + if (consistWagon) { + const { maxSequenceNo } = (await slotRepo + .createQueryBuilder('slot') + .select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo') + .where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId }) + .getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 }; + + const created = await slotRepo.save( + slotRepo.create({ + trainSetId: source.trainSetId, + wagonTypeId: consistWagon.wagonTypeId, + physicalWagonId: consistWagon.id, + // Plan-order key only — the consist is drawn in the train's coupling + // order (wagons.sequence_number), so appending here moves nothing. + // It just has to clear the (train_set_id, sequence_no) unique index. + sequenceNo: Number(maxSequenceNo) + 1, + capacityTons: roundTons( + Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons), + ), + lengthMeters: roundTons( + Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters), + ), + ...sourceLoadFields, + }), + ); + + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: created.id }); + } + await slotRepo.update(source.id, emptyLoadFields); + return; + } + + const target = targetSlot as TrainSetWagon; const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; for (const alloc of sourceAllocs) { 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 b0f58a67a..23cf35877 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 @@ -5,6 +5,7 @@ import { Container as ContainerIcon, Fuel, Gauge, + GripVertical, Package, TrainFront, Weight, @@ -202,11 +203,16 @@ function WagonCar({ const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—"); // The whole load drags as one unit (a 20ft pair never splits). Any OTHER - // wagon is a drop target: empty → move (a consist-only wagon repins), loaded - // → the two loads swap. The API validates wagon type + payload weight. + // wagon is a drop target: empty → the load moves onto it, loaded → the two + // loads swap. Either way the wagons stay exactly where they are coupled — + // only the cargo changes wagon. The API validates type + payload weight. const draggable = canRearrange && !isEmpty; const beingDragged = drag?.sourceWagonId === wagon.id; const dropEligible = Boolean(drag && !beingDragged); + // Say which of the two it will be BEFORE the drop — a swap displaces this + // wagon's own load, so it should never come as a surprise. + const dropIntent = dropEligible ? (isEmpty ? "move" : "swap") : null; + const dropColor = dropIntent === "swap" ? "orange" : "teal"; const endDrag = () => { onDragChange(null); setDropHover(false); @@ -258,9 +264,9 @@ function WagonCar({ ? "none" : "0 3px 10px rgba(15,41,27,0.08)", outline: dropHover - ? "2px solid var(--mantine-color-cyan-6)" + ? `2px solid var(--mantine-color-${dropColor}-6)` : dropEligible - ? "2px dashed var(--mantine-color-cyan-4)" + ? `2px dashed var(--mantine-color-${dropColor}-4)` : "none", outlineOffset: 2, overflow: "hidden", @@ -269,6 +275,35 @@ function WagonCar({ transition: "box-shadow 120ms ease, outline-color 120ms ease", }} > + {/* Drop intent — states the outcome while the load hovers here, so + a swap is never mistaken for dropping onto a free wagon. */} + {dropHover && dropIntent ? ( + + + {dropIntent === "swap" ? "SWAP LOADS" : "MOVE HERE"} + + + ) : null} + {/* top accent strip */} + {/* Staff kept reading a move as the train re-ordering itself, so say the + invariant out loud: the coupling order never changes, only the cargo. */} + {canRearrange ? ( + + + + Drag a wagon's load onto another wagon to move or swap it — the + wagons themselves keep their position in the train. + + + ) : null} + {locomotive ? : null} {wagons.length === 0 ? ( diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 2047bc933..8d7dab7b8 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -848,7 +848,14 @@ export interface CreateContractDto { freightType: ContractFreightType; serviceTypeId: string; - paymentCurrency: string; + /** + * Ignored on create — a contract always quotes in USD now. The billing + * currency is chosen per shipment (at booking, or on the shipment request + * when GL books on the customer's behalf). + * + * @deprecated + */ + paymentCurrency?: string; customsClearingEnabled?: boolean; customsClearingAgent?: string; equipmentReturn?: string;