fix issue

This commit is contained in:
Marshal
2026-08-27 20:58:44 +00:00
parent 1626903192
commit 8b8870e85e
8 changed files with 327 additions and 63 deletions

View File

@@ -1,6 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
bulkTonWagonsRequired,
bulkTonsPerWagonFor,
} from '../train-scheduling/train-capacity.util';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
@@ -107,3 +111,48 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
).rejects.toThrow(/already shares a wagon/i);
});
});
/**
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
* re-inflates to its pre-cancel count on the next allocation and each wagon
* carries tons / stale-count instead of the real even share.
*/
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
// 980T over 14 wagons (70T each), 2 wagons cancelled.
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
const droppedWeight = 140;
const wagonsCancelled = 2;
// The decrement applied in applyPaidCut's booking update.
const after = {
...before,
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
bulkRequestedWagons: Math.max(
0,
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
),
};
it('reallocates at the reduced count, not the pre-cancel one', () => {
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
});
it('keeps tons-per-wagon at the real even share', () => {
// Stale count would spread 840T over 14 wagons → 60T each.
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
});
it('cancelling every wagon leaves no requested count behind', () => {
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
expect(all).toBe(0);
expect(bulkTonWagonsRequired(
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
undefined,
'nw5',
70,
)).toBe(0);
});
});

View File

@@ -704,8 +704,21 @@ export class BookingWagonCancellationService {
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
// booking to its pre-cancel count on the next allocation (and shrinks
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
const requestedWagonsLeft = booking.bulkRequestedWagons
? Math.max(
0,
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
)
: null;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft),
...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft }
: {}),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),

View File

@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
// Wagon types carry the rated capacity the booking forms need to reject
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
.leftJoinAndSelect('contract.signatures', 'signatures')
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')

View File

@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
@@ -858,6 +860,29 @@ export class ContractsService {
);
}
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
// so they can reject a wagon count whose even share overloads a wagon —
// the client-side twin of ContractBookingService.assertWagonShareFits.
// The raw wagonTypes join rows are dropped: only the derived cap ships.
for (const scope of contract.cargoScope ?? []) {
const cargoType = scope.cargoType as
| (CargoType & { maxTonsPerWagon?: number | null })
| null
| undefined;
if (!cargoType) continue;
const allowed = (cargoType.wagonTypes ?? []).filter(
(wt) => Number(wt.capacityTons) > 0,
);
cargoType.maxTonsPerWagon = allowed.length
? Math.max(
...allowed.map((wt) =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
),
)
: null;
delete cargoType.wagonTypes;
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.