add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 17:30:00 +00:00
parent 5e10c97294
commit bfea9de660
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) {