mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #964 from Tria-plc/freight_feature/usermanagement
add dispute functionality for contract duty and implement collection…
This commit is contained in:
@@ -1133,7 +1133,12 @@ describe('TrainSchedulingService', () => {
|
||||
let slotB: Record<string, unknown>;
|
||||
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||||
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<string, unknown> = {}) => ({
|
||||
@@ -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<string, unknown>) => row),
|
||||
save: jest.fn((row: Record<string, unknown>) =>
|
||||
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<string, unknown>).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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ? (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background:
|
||||
dropIntent === "swap"
|
||||
? "rgba(253,126,20,0.14)"
|
||||
: "rgba(18,184,134,0.14)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="9px"
|
||||
fw={800}
|
||||
c={`${dropColor}.9`}
|
||||
style={{ letterSpacing: 0.6 }}
|
||||
>
|
||||
{dropIntent === "swap" ? "SWAP LOADS" : "MOVE HERE"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* top accent strip */}
|
||||
<Box
|
||||
style={{
|
||||
@@ -541,6 +576,18 @@ export const InteractiveTrainConsist = ({
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<Group gap={6} wrap="nowrap" mb={6} pl={2}>
|
||||
<GripVertical size={12} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="10px" c="dimmed">
|
||||
Drag a wagon's load onto another wagon to move or swap it — the
|
||||
wagons themselves keep their position in the train.
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
||||
{wagons.length === 0 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user