mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
add dispute functionality for contract duty and implement collection dates
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) {
|
||||
|
||||
Reference in New Issue
Block a user