Merge pull request #964 from Tria-plc/freight_feature/usermanagement

add dispute functionality for contract duty and implement collection…
This commit is contained in:
marshal
2026-07-26 20:30:31 +03:00
committed by GitHub
4 changed files with 185 additions and 30 deletions

View File

@@ -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 () => {

View File

@@ -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) {

View File

@@ -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 ? (

View File

@@ -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;