Enhance booking and signature functionalities

- Updated MySignaturePage title to Signature
This commit is contained in:
Marshal
2026-08-01 22:03:18 +00:00
parent ea9df9abbe
commit 53d8655dc3
26 changed files with 1050 additions and 19 deletions

View File

@@ -3131,6 +3131,14 @@ export class BookingBatchService implements OnModuleInit {
async intercityCapacity(scheduleId: string): Promise<{
budget: CorridorBudget;
needFor: (booking: Booking) => Capacity;
/**
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
* schedule's own wagon stock — so the same booking reads differently on a
* different train. Empty when the stock can't be resolved.
*/
breakdownFor: (
booking: Booking,
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -3145,7 +3153,24 @@ export class BookingBatchService implements OnModuleInit {
// still accepts ride-alongs on its empty legs — that is the whole point
// of the ride-along flow.
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
// Physical stock of THIS schedule's train (built consist, or the yard fleet
// it will draw from) — what makes the breakdown train-specific.
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return {
budget,
needFor: (booking) => this.needFor(booking, wagonDims),
breakdownFor: (booking) =>
this.wagonBreakdownFor(
booking,
wagonDims,
stock.remainingByTypeId,
stock.codesByTypeId,
),
};
}
/**
@@ -4094,6 +4119,78 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
*
* `wagonsFor` sizes the booking on ONE representative type (the first the
* cargo type allows), which is all the abstract budget needs. Staff placing a
* ride-along need the physical picture: how many of each type this schedule
* must actually give up. So each allowed type is sized on its OWN capacity and
* items-fit, then filled greedily from the type with the largest per-wagon
* take, bounded by what the schedule has left of it.
*
* Because the stock is per-schedule, the same booking breaks down differently
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
* the booking's types are unconfigured or the train stocks none of them — the
* caller then shows the plain total.
*/
private wagonBreakdownFor(
booking: Booking,
wagonDims: WagonDims,
stockByTypeId: Map<string, number>,
codesByTypeId: Map<string, string>,
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
const total = this.wagonsFor(booking, wagonDims);
if (total <= 0) return [];
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
// that swallows more of the booking per wagon needs fewer wagons.
const options = this.allowedDimsWithTypes(booking, wagonDims)
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => {
const wagonTypeId = o.wagonTypeId as string;
const wagonsIfAlone = Math.max(
1,
bulkItemWagonsRequired(
booking,
o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
) ||
(o.dims.capacityTons > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
: total),
);
return {
wagonTypeId,
code: codesByTypeId.get(wagonTypeId) ?? '—',
available: stockByTypeId.get(wagonTypeId) ?? 0,
// Share of the whole booking one wagon of this type carries.
takePerWagon: 1 / wagonsIfAlone,
};
})
.sort((a, b) => b.takePerWagon - a.takePerWagon);
if (!options.length) return [];
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
let remaining = 1;
for (const option of options) {
if (remaining <= 1e-9) break;
const wagons = Math.min(
option.available,
Math.ceil(remaining / option.takePerWagon),
);
if (wagons <= 0) continue;
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
remaining -= wagons * option.takePerWagon;
}
// The train cannot hold the whole booking in the types it stocks — the
// `fits` check already fails it; report only what it CAN take.
return out;
}
private fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&

View File

@@ -0,0 +1,108 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The intercity ride-along board shows WHICH wagon types a booking takes from
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
* wagons. Because the split is drawn against that schedule's own stock, the
* same booking must read differently on a different train.
*/
describe('BookingBatchService — intercity wagon breakdown', () => {
const N35 = 'wagon-type-n35';
const PW2 = 'wagon-type-pw2';
const dims = (capacityTons: number) => ({
capacityTons,
lengthMeters: 14,
tareWeightTons: 20,
});
const wagonDims = {
bulk: dims(60),
container: dims(60),
byWagonTypeId: new Map([
[N35, dims(60)],
[PW2, dims(20)],
]),
};
const codes = new Map([
[N35, 'N35'],
[PW2, 'PW2'],
]);
/**
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
*/
const perItemBooking = {
id: 'booking-1',
freightType: 'BULK',
cargoTotalWeightVgm: 400,
bulkTotalWeightTons: 800,
bookingContainers: [],
cargoType: {
wagonTypes: [{ id: N35 }, { id: PW2 }],
itemsPerWagonMap: {},
},
} as unknown as Booking;
const service = Object.create(
BookingBatchService.prototype,
) as BookingBatchService;
const breakdown = (
booking: Booking,
stock: Map<string, number>,
): Array<{ code: string; wagons: number }> =>
(
service as unknown as {
wagonBreakdownFor: (
b: Booking,
d: typeof wagonDims,
s: Map<string, number>,
c: Map<string, string>,
) => Array<{ code: string; wagons: number }>;
}
)
.wagonBreakdownFor(booking, wagonDims, stock, codes)
.map(({ code, wagons }) => ({ code, wagons }));
it('takes the highest-capacity type first when the train stocks plenty', () => {
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
});
it('falls back to the smaller type for the remainder when the big one runs short', () => {
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
expect(rows[1].code).toBe('PW2');
expect(rows[1].wagons).toBeGreaterThan(0);
});
it('reads differently on a train that stocks only the small type', () => {
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
});
it('honours the configured items-per-wagon fit over raw tonnage', () => {
// Floor space binds before weight: an N35 physically holds 20 of these
// items even though 30 would fit by weight → 20 wagons, not 14.
const floorBound = {
...perItemBooking,
cargoType: {
wagonTypes: [{ id: N35 }],
itemsPerWagonMap: { [N35]: 20 },
},
} as unknown as Booking;
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
{ code: 'N35', wagons: 20 },
]);
});
it('returns nothing when the train stocks none of the allowed types', () => {
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
});
});

View File

@@ -155,12 +155,17 @@ export class IntercityService {
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
};
}),
};
}
@@ -200,7 +205,9 @@ export class IntercityService {
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
cargoType: true,
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
// check must size the booking exactly as the candidate list did.
cargoType: { wagonTypes: true },
},
});
if (!booking) {
@@ -326,6 +333,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
@@ -355,6 +366,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)

View File

@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
).rejects.toThrow(/free only 5/);
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })
.effectiveWagonsRequired(booking);
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
// ceil(20/4) = 5 wagons.
const perItemBooking = (wagonsRequired: number | null) => ({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired,
cargoType: {
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
itemsPerWagonMap: { 'wt-nw5': 4 },
},
});
it('overrides a stale too-small stamp with the item-aware recompute', () => {
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
expect(effective(perItemBooking(1))).toBe(5);
});
it('keeps a stored stamp that is at least the recompute', () => {
expect(effective(perItemBooking(7))).toBe(7);
});
it('trusts the stamp when BULK cargo relations are not loaded', () => {
expect(
effective({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired: 5,
cargoType: null,
}),
).toBe(5);
});
});
});

View File

@@ -7819,7 +7819,7 @@ export class TrainSchedulingService {
*/
private effectiveWagonsRequired(booking: Booking): number {
const stored = Number(booking.wagonsRequired);
if (stored > 0) return Math.ceil(stored);
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
@@ -7827,7 +7827,15 @@ export class TrainSchedulingService {
booking.freightType === 'BULK' && bulkCapacities.length
? Math.max(...bulkCapacities)
: undefined;
return wagonsRequiredForBooking(booking, bulkCapacity);
// BULK with no cargo relations loaded: recomputing would size against a
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
return storedCeil;
}
// Stored is a candidate, never an early return (batch parity): rows
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
}
/**