diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index ffcf936bd..cb9cc8ae1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1516,6 +1516,7 @@ export class BookingBatchService implements OnModuleInit { stock, leg, this.scarcityRankForPool([primary], allowedWagonTypes), + wagonTypeIds, ) : null; const seated = useSmart @@ -2357,7 +2358,7 @@ export class BookingBatchService implements OnModuleInit { !perItemBulk && wagonTypeIds.length > 0; const smart = useSmart - ? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank) + ? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank, wagonTypeIds) : null; const admitted = useSmart ? smart != null && budget.fits(smart.need, leg) @@ -2653,7 +2654,14 @@ export class BookingBatchService implements OnModuleInit { const leg = legOn(t); if (leg == null) continue; if (useSmart) { - const probe = this.smartBulkNeed(booking, wagonDims, t.stock, leg, scarcityRank); + const probe = this.smartBulkNeed( + booking, + wagonDims, + t.stock, + leg, + scarcityRank, + wagonTypeIds, + ); if (probe != null && t.budget.fits(probe.need, leg)) { smart = probe; target = t; @@ -2858,10 +2866,14 @@ export class BookingBatchService implements OnModuleInit { // other 4 leave as the usual remainder booking, instead of paying for // 20 and stalling at allocation on wagon 17. if (cappedBulk && wagonDims) { - const best = this.allowedDimsWithTypes(booking, wagonDims) - .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => - o.wagonTypeId != null, - ) + // Types resolved from the id list (join tables), never the pool + // entity's unloaded cargoType.wagonTypes relation — see smartBulkNeed. + const best = [...new Set(wagonTypeIds)] + .map((wagonTypeId) => ({ + wagonTypeId, + dims: wagonDims.byWagonTypeId.get(wagonTypeId), + })) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null) .map((o) => ({ ...o, free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, @@ -5074,7 +5086,7 @@ export class BookingBatchService implements OnModuleInit { Number(b.bulkTotalWeightTons ?? 0) > 0 && Number(b.cargoTotalWeightVgm ?? 0) > 0; if (b.freightType === "BULK" && !perItemBulk && typeIds.length) { - const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank); + const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank, typeIds); if (smart) { for (const part of smart.perType) { ledger.consume([part.wagonTypeId], part.wagons, leg); @@ -5168,9 +5180,19 @@ export class BookingBatchService implements OnModuleInit { stock: WagonStockLedger, leg: CorridorLeg, scarcityRank: Map, + /** + * Wagon-type ids this booking may ride, from {@link loadAllowedWagonTypeIds} + * — NEVER from `booking.cargoType.wagonTypes`. The batch pool finders + * deliberately do not join that relation (hot path), so on a pool entity + * it is always empty; resolving through it made every PER_TON bulk booking + * unseatable — no whole fit and no partial offer, silently READY forever + * (the S-2026-00020 / BK-2026-000036 incident). + */ + wagonTypeIds: readonly string[], ): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null { - const options = this.allowedDimsWithTypes(booking, wagonDims) - .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.wagonTypeId != null) + const options = [...new Set(wagonTypeIds)] + .map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId) })) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null) .map((o) => ({ ...o, free: stock.availableFor([o.wagonTypeId], leg), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts index 922ec2335..dd7797d33 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -22,17 +22,21 @@ describe('BookingBatchService.smartBulkNeed', () => { s: WagonStockLedger, l: { fromEdge: number; toEdge: number }, r: Map, + ids: readonly string[], ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; } - ).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank); + ).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank, allowedIds); const nw5 = { id: 'wt-nw5', capacityTons: 70 }; const pw2 = { id: 'wt-pw2', capacityTons: 70 }; + // Shaped like a BATCH POOL entity: cargoType WITHOUT the wagonTypes + // relation (the pool query never joins it) — allowed types must come from + // the ids parameter, or every pool bulk booking reads as unseatable. const perishable = { id: 'cargo-perishable', - wagonTypes: [nw5, pw2], tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, }; + const allowedIds = [nw5.id, pw2.id]; const wagonDims = { container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, @@ -113,9 +117,10 @@ describe('BookingBatchService.smartBulkNeed', () => { s: WagonStockLedger, l: { fromEdge: number; toEdge: number }, r: Map, + ids: readonly string[], ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; } - ).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested); + ).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested, allowedIds); expect(smart).not.toBeNull(); expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]); });