diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -0,0 +1,98 @@ +import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; + +/** Minimal Rate factory for the builder unit tests. */ +function rate(partial: Partial): Rate { + return { + trigger: 'ALWAYS', + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + ...partial, + } as Rate; +} + +describe('ContractRateScheduleBuilder', () => { + const LIVE: Rate[] = [ + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 200, + rateUnit: 'PER_CONTAINER', + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }), + rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import + rateType: 'CONTAINER_EXPORT', + rateValue: 819, + originYard: { label: 'GMP' } as never, + destinationYard: { label: 'SGTD' } as never, + }), + rate({ + appliesTo: 'BULK', // wrong freight — filtered out for a container contract + tradeDirection: 'IMPORT', + rateType: 'BULK_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 100, + }), + rate({ + appliesTo: 'FIRST_MILE', + trigger: 'ALWAYS', + tradeDirection: null, + rateUnit: 'PER_CONTAINER', + rateValue: 50, + }), + rate({ + appliesTo: 'OTHER', + trigger: 'CUSTOMS_CLEARANCE', + tradeDirection: null, + rateType: 'CUSTOMS_CLEARANCE', + rateUnit: 'FLAT', + rateValue: 120, + }), + ]; + + const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) }; + return new ContractRateScheduleBuilder(service as never).build(dir, freight); + }; + + it('shows only import container lanes for an import container contract', async () => { + const s = await build('IMP', 'CON'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ + route: 'Negad → Mojo Dry Port', + cargo: '40ft GP', + currency: 'USD', + amount: '200', + unit: 'per container', + }); + }); + + it('always lists route-agnostic services and surcharges', async () => { + const s = await build('IMP', 'CON'); + expect(s.additionalServices).toHaveLength(1); + expect(s.additionalServices[0].route).toBe('First-mile pickup by truck'); + expect(s.surcharges).toHaveLength(1); + expect(s.surcharges[0].route).toBe('Customs clearance service'); + }); + + it('excludes container lanes from a bulk contract', async () => { + const s = await build('IMP', 'BULK'); + expect(s.freightLanes).toHaveLength(1); + expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' }); + }); + + it('flags an empty schedule when nothing priced matches', async () => { + const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) }; + const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON'); + expect(s.isEmpty).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the `pricing` article body of the six seeded contract templates to + * the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per + * wagon") are now rendered from the LIVE rate config instead of frozen prose, + * so any template whose pricing article still carries a hardcoded price token + * is rewritten to the current seed text. + * + * The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original + * prose (which always quoted a currency + figure) and matches neither an + * already-migrated body nor a hand-edited one that adopted the schedule + * wording — so admin edits are preserved. Idempotent: after the rewrite the + * price token is gone, so a re-run is a no-op. Fresh databases seed the new + * text directly (CreateContractTemplates imports the same seed), making this + * a targeted backfill for databases seeded before the seed changed. + */ +const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]'; + +export class RefreshContractPricingArticles2360000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((a) => a.id === 'pricing'); + if (!pricing) continue; + + // Rewrite only the article whose id = 'pricing', in place, and only when + // its body still quotes a hardcoded currency figure. jsonb_agg keeps the + // rest of the article (id/title/order) and every other article intact. + await queryRunner.query( + ` + UPDATE freight.contract_templates AS t + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true) + ELSE elem + END + ORDER BY ord + ) + FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord) + ), + updated_at = now() + WHERE t.code = $1 + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(t.articles) AS x + WHERE x->>'id' = 'pricing' + AND x->>'body' ~ $3 + ); + `, + [seed.code, pricing.body, HARDCODED_PRICE_TOKEN], + ); + } + } + + /** + * Irreversible in practice — the original per-lane figures are not restored. + * A no-op down keeps the migration reversible-by-contract without + * resurrecting stale hardcoded prices. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 690b66385..aacc68012 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -42,6 +42,7 @@ export class BookingTransitionService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, + @Inject(forwardRef(() => BookingContractService)) private readonly contractService: BookingContractService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @@ -1049,7 +1050,25 @@ export class BookingTransitionService { booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); if (isExportTrain) { - await this.bookingBatchService.pickExportSchedule(scheduledBooking); + // With export split ON the booking no longer has to ride ONE train whole: + // the largest fitting part is offered and the leftover rebooks on the next + // train. So the day is only unbookable when NO export train that day has + // any room at all — reject on the day total, not on a single-train fit. + // With the flag off this stays the strict whole-booking gate. + if (process.env.FREIGHT_EXPORT_SPLIT === "true") { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + scheduledBooking, + eatDay(date), + "EXPORT", + ); + if (!fitting.length) { + throw new ConflictException( + "No export train on this day has space left — pick another shipment day.", + ); + } + } else { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } } await this.bookingsRepository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 826bc33b0..8ef819eaf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * Outstanding split remainder of a contract: what was booked in the first split + * booking's pre-split snapshot MINUS everything currently booked. Container + * contracts report per size; bulk reports one tonnage figure. `null` when the + * contract has no live split chain. Consumed by the remainder-placement engine + * to size the auto-created remainder booking. + */ +export type SplitOutstanding = { + bySize: Map; + bulk: { total: number; outstanding: number } | null; +}; + /** * The single create path for shipment bookings under a contract. * @@ -966,9 +979,11 @@ export class ContractBookingService { * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ - private async splitOutstanding( - contract: Contract, - ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + /** + * Public: the remainder-placement engine reads this to size the auto-created + * remainder booking. Returns `null` when there is no live split chain. + */ + async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') @@ -1015,6 +1030,25 @@ export class ContractBookingService { const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; + + // With export split ON a booking no longer has to ride ONE train whole: the + // largest fitting part is offered and the leftover is rebooked on the next + // train. Rejecting on the single-train fit here would block exactly the + // bookings the split exists to serve — including the auto-created remainder, + // which by definition did not fit the train it was split off. Fall back to + // the day total: unbookable only when NO export train that day has room. + if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { + const fitting = await this.bookingBatchService.fittingTrainsForDay( + probe, + eatDay(new Date(dto.scheduledDate)), + 'EXPORT', + ); + if (fitting.length > 0) return; + throw new BadRequestException( + 'No export train on this day has space left — pick another shipment day.', + ); + } + throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); @@ -1636,6 +1670,8 @@ export class ContractBookingService { isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 5507692d9..41971bbf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -74,9 +74,14 @@ export class CreateRateDto { @Transform(({ value }) => Number(value)) rateValue!: number; - @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @ApiPropertyOptional({ + enum: RATE_UNITS, + description: + 'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.', + }) + @IsOptional() @IsIn([...RATE_UNITS]) - rateUnit!: string; + rateUnit?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index edd9ac9f0..488865f38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -68,15 +68,21 @@ export class RatesService { private resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], - requestedUnit: Rate['rateUnit'], + requestedUnit: Rate['rateUnit'] | undefined, ): Rate['rateUnit'] { - // Overweight is per-ton, full stop. + // Overweight is per-ton, full stop — the admin form hides the unit field + // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { - const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + const allowed = allowedRateUnits({ appliesTo, trigger }); + if (!requestedUnit) { throw new BadRequestException( - `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, + ); + } + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); } return requestedUnit; @@ -272,7 +278,11 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + const rateUnit = this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + ); await this.assertNoDuplicatePattern({ rateType, 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 866885074..c06f0ee81 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 @@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, @@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + @Optional() + @Inject(forwardRef(() => RemainderPlacementService)) + private readonly remainderPlacement?: RemainderPlacementService, ) {} + /** + * Auto-place a paid booking's split remainder onto the next fitting train. + * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true. + */ + private get autoRemainderEnabled(): boolean { + return process.env.FREIGHT_AUTO_REMAINDER === "true"; + } + + /** + * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks + * on the next train). Separate flag from auto-remainder: export touches the + * FCFS money path, so partial-offer can be enabled independently. + */ + private get exportSplitEnabled(): boolean { + return process.env.FREIGHT_EXPORT_SPLIT === "true"; + } + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { const groups = await this.openRouteDayGroups(); @@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit { // to the offered part before it boards (remainder returns to the contract cap). if (this.splitService) { await this.splitService.applySplit(bookingId); + + // The split only happens on payment (here) — so auto-placing the remainder + // also only happens once the customer has accepted+paid. Re-read to see if + // applySplit actually reduced this booking (an open offer existed); if so, + // auto-create + place the remainder booking on the next fitting train. + // applySplit committed its own transaction before returning, so this reads + // the reduced lines. Best-effort: a placement failure never blocks the + // paid booking from boarding — the remainder falls back to manual rebook. + if (this.autoRemainderEnabled && this.remainderPlacement) { + const split = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + // Export remainders only auto-place when export split is on — otherwise + // an export booking never splits in the first place. + const directionOn = + split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled; + if (split?.isSplit && directionOn) { + await this.remainderPlacement + .placeRemainder(split) + .catch((err) => + this.logger.error( + `Auto-place remainder failed for ${split.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } } const linked = @@ -731,6 +780,76 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Trains that can carry a booking's leg on a given day, earliest departure + * first, each with the largest number of wagons it could still admit for the + * booking's wagon type. Direction-filtered: EXPORT bookings see export trains, + * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL + * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a + * non-primary allowed type still counts. The remainder placer uses this to + * pick the next fitting train; the `free` wagon count is the best across the + * allowed types (a train fits under whichever allowed type gives most room). + */ + async fittingTrainsForDay( + booking: Booking, + day: string, + direction: "IMPORT" | "EXPORT", + ): Promise> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== "FULL" && + (direction === "EXPORT" + ? s.direction === "EXPORT" + : s.direction !== "EXPORT"), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const dimsOptions = this.dimsForAllowed(booking, wagonDims); + const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = []; + + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // Best usable wagons across the allowed types — a train fits under + // whichever configured wagon type gives it the most room. + let freeWagons = 0; + for (const dims of dimsOptions) { + const w = this.bookableWithin(room, dims).wagons; + if (w > freeWagons) freeWagons = w; + } + if (freeWagons > 0) { + out.push({ + scheduleId: schedule.id, + departure: schedule.scheduledDepartureDate!, + freeWagons, + }); + } + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -789,6 +908,58 @@ export class BookingBatchService implements OnModuleInit { return { freeWagons, need, trainsForDay }; } + /** + * Export split: no single train carries the whole booking, so offer the + * largest fitting part on the export train with the most room for its leg. + * Returns true when an offer was opened (the caller must NOT then reserve — + * the offer already opened its own pay window), false when the booking fits + * whole somewhere (normal FCFS path) or no meaningful partial exists. + * + * Only the offer is written here: the booking is reduced to the offered part + * on payment (applySplit), and the leftover is auto-placed afterwards. So an + * unpaid export booking stays whole and the customer may still cancel it. + */ + private async tryExportPartialOffer(booking: Booking): Promise { + if (!this.splitService) return false; + const report = await this.exportSpaceReport(booking); + // A train fits it whole — nothing to split, take the normal path. + if (report.scheduleId) return false; + if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false; + + if (!booking.scheduledDate) return false; + const day = eatDay(new Date(booking.scheduledDate)); + const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT"); + if (!fitting.length) return false; + // Most room first — the largest single part ships now, the smallest leftover + // is what has to find another train. + const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0]; + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + target.scheduleId, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return false; + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) return false; + + const offered = await this.tryPartialOffer( + booking, + schedule.id, + budget.remainingFor(leg), + report.need, + ); + if (!offered) return false; + this.logger.log( + `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` + + `${schedule.id} — leftover rebooks on the next train once paid.`, + ); + this.notifyBoardChanged(schedule.id, "batch_fill"); + return true; + } + /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready @@ -800,6 +971,15 @@ export class BookingBatchService implements OnModuleInit { async acceptExportBooking(booking: Booking): Promise { const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { + // Export split: when no single train carries the whole booking, offer the + // largest fitting part instead of failing the accept. The customer pays + // that part; on payment applySplit reduces this booking to it and the + // leftover is auto-placed as its own booking on the next train. Pairs are + // excluded (handled below) — a shared wagon is never split. + if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) { + const offered = await this.tryExportPartialOffer(booking); + if (offered) return; + } const scheduleId = await this.pickExportSchedule(booking); await this.reserveOnExport([booking], scheduleId); return; @@ -1852,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit { } /** - * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be - * offered a partial (split-on-payment). Consolidated pairs never split (both-or- - * neither shared wagon) and government bookings never split (they preempt). + * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a + * partial (split-on-payment). Consolidated pairs never split (both-or-neither + * shared wagon) and government bookings never split (they preempt). + * + * IMPORT is always eligible. EXPORT is eligible only when export split is + * enabled: export historically rides one train whole, so splitting it changes + * the FCFS money path — each split part still rides ONE train whole, and the + * leftover becomes its own booking on the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { + const directionOk = + booking.tradeDirection === "IMPORT" || + (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && !booking.isGovernment && - booking.tradeDirection === "IMPORT" && + directionOk && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && this.splitService != null ); @@ -3174,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * EVERY wagon-type dimension a booking may ride — its cargo/container type's + * full allowed (many-to-many) wagon-type list, not just the first like + * {@link dimsFor}. The remainder placer needs the whole set so a train that + * stocks a non-primary allowed type still counts as fitting: a container type + * mapped to both NW5 and (say) NW7 must be measured against whichever a given + * train actually has free. Deduped by wagon-type id; falls back to the single + * representative dims when no allowed type is configured. + */ + private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const ids = + booking.freightType === "BULK" + ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id) + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wt) => wt.id); + const seen = new Set(); + const dims: PerWagonDims[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + seen.add(id); + const d = wagonDims.byWagonTypeId.get(id); + if (d) { + dims.push({ + ...d, + capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }); + } + } + return dims.length ? dims : [fallback]; + } + /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 3d505e113..e17445b4d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -150,10 +150,18 @@ export class BookingNotifierService { ): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const leftover = totalWagons - offeredWagons; + // With auto-placement on, the leftover is booked FOR the customer on another + // train (its own invoice) — telling them to rebook it themselves would be + // wrong. Without it, the leftover returns to the contract to rebook. + const leftoverCopy = + process.env.FREIGHT_AUTO_REMAINDER === 'true' + ? `The remaining ${leftover} will be booked for you on another train, with its own invoice. ` + : `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `; const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + - `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + leftoverCopy + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); // HIGH: a split is a change to what the customer ordered AND a live payment @@ -164,6 +172,23 @@ export class BookingNotifierService { }); } + /** + * The wagons that did not fit the train the customer just paid for have been + * auto-booked as their own booking (`remainder`) — they ride another train and + * are billed separately. Sent instead of leaving the customer to rebook. + */ + remainderPlaced(remainder: Booking, parentReference: string): void { + const msg = + `The wagons left over from booking ${parentReference} have been booked as ` + + `${remainder.reference ?? remainder.id} on another train. ` + + `It carries its own invoice — pay it to secure that slot.`; + void this.notifyContact(remainder, msg, 'REMAINDER BOOKED'); + this.inApp(remainder, 'Leftover wagons booked', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts new file mode 100644 index 000000000..55cacaf03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts @@ -0,0 +1,211 @@ +import { RemainderPlacementService } from './remainder-placement.service'; + +/** + * The remainder placer reconstructs the outstanding split remainder as a new + * booking. The delicate parts under test: bulk sizes from the outstanding tons; + * container recovers real numbers from the SOFT-DELETED units (never fabricates) + * and throws on a shortfall; and nothing is placed when there's no outstanding + * or no fitting train. + */ +describe('RemainderPlacementService', () => { + const DAY = '2026-07-20'; + + function make(opts: { + freightType: 'CONTAINER' | 'BULK'; + contractKind?: 'ONE_TIME' | 'GENERAL'; + outstanding: unknown; + createThrows?: Error; + deferredUnits?: Array<{ + containerNumber: string; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + }>; + fittingTrains?: Array<{ scheduleId: string }>; + }) { + const contract = { + id: 'c-1', + freightType: opts.freightType, + contractKind: opts.contractKind ?? 'ONE_TIME', + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }; + const createUnderContract = opts.createThrows + ? jest.fn().mockRejectedValue(opts.createThrows) + : jest + .fn() + .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] }); + const contractBookingService = { + splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding), + createUnderContract, + }; + const bookingBatchService = { + fittingTrainsForDay: jest + .fn() + .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]), + }; + // getRepository is only hit on the container path (recoverDeferredUnits). + const lineRepo = { + find: jest.fn().mockResolvedValue([{ id: 'line-1' }]), + }; + const unitRepo = { + find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => { + const n = entity?.name ?? ''; + if (n.includes('Unit')) return unitRepo; + return lineRepo; + }), + }; + const notifier = { remainderPlaced: jest.fn() }; + const service = new RemainderPlacementService( + dataSource as never, + contractsRepository as never, + contractBookingService as never, + bookingBatchService as never, + notifier as never, + ); + return { + service, + createUnderContract, + contractBookingService, + bookingBatchService, + notifier, + }; + } + + const splitBooking = { + id: 'bk-1', + reference: 'BKG-1', + contractId: 'c-1', + scheduledDate: new Date('2026-07-20T06:00:00Z'), + createdByUserId: 'u-1', + } as never; + + it('sizes a BULK remainder from the outstanding tons', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]); + expect(dto.scheduledDate).toBe(DAY); + }); + + it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => { + const deferredUnits = [ + { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true }, + { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true }, + ]; + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]), + bulk: null, + }, + deferredUnits, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBe('rem-1'); + const dto = createUnderContract.mock.calls[0][1]; + expect(dto.containers).toHaveLength(1); + const line = dto.containers[0]; + expect(line.containerSize).toBe('40ft'); + expect(line.quantity).toBe(2); + expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([ + 'ABCD1234567', + 'ABCD7654321', + ]); + expect(line.reeferQuantity).toBe(1); + expect(line.hazardousQuantity).toBe(1); + }); + + it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => { + const { service, createUnderContract } = make({ + freightType: 'CONTAINER', + outstanding: { + bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]), + bulk: null, + }, + deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3 + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no outstanding remainder', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); + + it('tells the customer the leftover wagons were booked on another train', async () => { + const { service, notifier } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + await service.placeRemainder(splitBooking); + expect(notifier.remainderPlaced).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rem-1' }), + 'BKG-1', + ); + }); + + it('never double-books the leftover when two payments land together', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + // Both callers enter before either create commits. + await Promise.all([ + service.placeRemainder(splitBooking), + service.placeRemainder(splitBooking), + ]); + expect(createUnderContract).toHaveBeenCalledTimes(1); + }); + + // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's + // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and + // either drops a real remainder or double-draws the cap, so we must not place. + it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => { + const { service, createUnderContract, contractBookingService } = make({ + freightType: 'BULK', + contractKind: 'GENERAL', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled(); + }); + + // The paid booking has already boarded — a create-gate rejection (e.g. the + // export whole-train gate) must leave the remainder rebookable, not escape. + it('swallows a create rejection and leaves the remainder for manual rebook', async () => { + const { service } = make({ + freightType: 'BULK', + outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, + createThrows: new Error('Not enough train space for this day.'), + }); + await expect(service.placeRemainder(splitBooking)).resolves.toBeNull(); + }); + + it('is a no-op when the contract has no split chain', async () => { + const { service, createUnderContract } = make({ + freightType: 'BULK', + outstanding: null, + }); + const id = await service.placeRemainder(splitBooking); + expect(id).toBeNull(); + expect(createUnderContract).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts new file mode 100644 index 000000000..85b6d0878 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -0,0 +1,342 @@ +import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common'; +import { DataSource, IsNull, Not } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { + ContractBookingService, + SplitOutstanding, +} from '../contracts/contract-booking.service'; +import { ContractsRepository } from '../contracts/contracts.repository'; +import { + CreateBookingUnderContractDto, + CreateContainerUnitDto, +} from '../contracts/dto/create-booking-under-contract.dto'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingNotifierService } from './booking-notifier.service'; +import { eatDay } from './batch-window.util'; + +/** + * Auto-creates and places the OUTSTANDING split remainder of a contract as a new + * booking, so the customer doesn't have to manually rebook the wagons that did + * not fit the train they just paid for. + * + * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only + * once the customer has actually accepted+paid the offered part. Before payment + * nothing is split: the booking stays whole and the customer may still edit or + * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}. + * + * IMPORT/DOMESTIC: the remainder booking is created with the next fitting + * shipment day set and then follows the normal windowed batch flow (train + * assigned at window close, paid in its own window). It is NOT force-reserved on + * a specific train — import is not FCFS. + * + * Container reconstruction is HYBRID: the remainder's quantities come from the + * split snapshot (`splitOutstanding`), but the actual container numbers / VGM / + * seals are read back from the units `applySplit` SOFT-DELETED off the parent + * (they survive as valid ISO records). We never `restore()` those rows — the new + * booking gets fresh rows — so the contract cap is never double-counted. + */ +@Injectable() +export class RemainderPlacementService { + private readonly logger = new Logger(RemainderPlacementService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly contractsRepository: ContractsRepository, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBookingService: ContractBookingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Create + place the outstanding split remainder of the contract that owns + * `splitBooking`. No-op when there is no live remainder or no fitting day. + * Returns the created remainder booking id, or null when nothing was placed + * (residual falls back to the customer's manual rebook, as today). + */ + async placeRemainder(splitBooking: Booking): Promise { + if (!splitBooking.contractId) return null; + // Two payment webhooks for the same contract landing together would both see + // the remainder as unbooked (the placing create has not committed yet) and + // each create one — double-booking the leftover. Serialize per contract: the + // second caller returns immediately and the first one's create is what the + // (now smaller) outstanding reflects. + if (this.inFlight.has(splitBooking.contractId)) { + this.logger.debug( + `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`, + ); + return null; + } + this.inFlight.add(splitBooking.contractId); + try { + return await this.placeRemainderInner(splitBooking); + } finally { + this.inFlight.delete(splitBooking.contractId); + } + } + + /** Contracts with a placement in flight — see {@link placeRemainder}. */ + private readonly inFlight = new Set(); + + private async placeRemainderInner( + splitBooking: Booking, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations( + splitBooking.contractId!, + ); + if (!contract) return null; + + // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total + // from a SINGLE booking's pre-split snapshot, which is only coherent when + // the contract has exactly one live chain — that is the ONE_TIME invariant + // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL + // contract with other live bookings the subtraction mixes scopes: it either + // clamps to 0 and silently drops a real remainder, or sizes one that then + // draws the quantity cap a second time. GENERAL remainders keep the existing + // manual-rebook behaviour until the remainder can be derived from the + // offer's own dropped lines rather than from the contract-wide ledger. + if (contract.contractKind !== 'ONE_TIME') { + this.logger.debug( + `Contract ${contract.id} is ${contract.contractKind} — remainder left ` + + `for manual rebook (auto-placement is ONE_TIME only).`, + ); + return null; + } + + const outstanding = await this.contractBookingService.splitOutstanding( + contract, + ); + if (!outstanding || !this.hasOutstanding(contract, outstanding)) { + return null; + } + + // The next fitting day: the earliest day on/after the split booking's own day + // that still has an import train with room for this cargo type. We reuse the + // split booking as the capacity probe — it carries the leg + cargo relations. + const day = await this.nextFittingDay(splitBooking); + if (!day) { + this.logger.warn( + `No train with room for the remainder of contract ${contract.id} ` + + `(booking ${splitBooking.reference}) — left for manual rebook.`, + ); + return null; + } + + let dto: CreateBookingUnderContractDto; + try { + dto = await this.buildRemainderDto( + contract, + outstanding, + splitBooking.id, + day, + ); + } catch (err) { + // A reconstruction shortfall (fewer recoverable units than outstanding) + // must NOT fabricate container numbers — fail loudly, leave manual rebook. + this.logger.error( + `Could not reconstruct the remainder of contract ${contract.id}: ` + + `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`, + ); + return null; + } + + // Any create-gate rejection (no train space, cap, container clash) must not + // escape: the customer's paid booking has already boarded, and a thrown + // error here would only be logged upstream while the remainder vanished + // silently. Fall back to leaving it rebookable, which is the pre-feature + // behaviour, and say so in the log. + let created: Awaited< + ReturnType + >; + try { + created = await this.contractBookingService.createUnderContract( + contract.id, + dto, + { id: splitBooking.createdByUserId ?? undefined }, + // System actor: a permission-bag carrying the contract create-booking key + // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B + // contracts; harmless for customer (Path A) contracts. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + } catch (err) { + this.logger.error( + `Could not create the remainder booking for contract ${contract.id} ` + + `(from ${splitBooking.reference}): ${ + err instanceof Error ? err.message : String(err) + } — left for manual rebook.`, + ); + return null; + } + // EXPORT is FCFS — there is no window to wait for, so the remainder is + // reserved on the next export train right away (its own pay window opens). + // If it does not fit one train whole either, the export accept offers it a + // partial and the chain repeats on ITS payment: each pass leaves a strictly + // smaller remainder, so it terminates at the day's train count. + // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next + // fitting day and rides the normal windowed batch flow. + if (splitBooking.tradeDirection === 'EXPORT') { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ + where: { id: created.booking.id }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (fresh) { + await this.bookingBatchService + .acceptExportBooking(fresh) + .catch((err) => + // No export train took it — it stays created and rebookable, which + // is the same place a customer-driven rebook would leave it. + this.logger.warn( + `Export remainder ${fresh.reference} created but not reserved: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + } + + this.notifier.remainderPlaced( + created.booking, + splitBooking.reference ?? splitBooking.id, + ); + this.logger.log( + `Auto-placed split remainder of contract ${contract.id} as booking ` + + `${created.booking.reference} on ${day}.`, + ); + return created.booking.id; + } + + private hasOutstanding( + contract: Contract, + outstanding: SplitOutstanding, + ): boolean { + if (contract.freightType === 'CONTAINER') { + return [...outstanding.bySize.values()].some((s) => s.outstanding > 0); + } + return (outstanding.bulk?.outstanding ?? 0) > 0.001; + } + + /** + * The shipment day to create the remainder on — the split booking's own day. + * + * EXPORT is FCFS and must actually board a train that day, so a day with NO + * export train having room is rejected (null → left for manual rebook on a day + * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is + * assigned by the batch engine at window close, not now, and the window may + * still free up — forcing a different day here would override the customer's + * binding shipment day. + */ + private async nextFittingDay(booking: Booking): Promise { + if (!booking.scheduledDate) return null; + const day = eatDay(new Date(booking.scheduledDate)); + if (booking.tradeDirection !== 'EXPORT') return day; + + const fitting = await this.bookingBatchService.fittingTrainsForDay( + booking, + day, + 'EXPORT', + ); + return fitting.length > 0 ? day : null; + } + + /** + * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the + * outstanding tonnage directly. Container reads the deferred (soft-deleted) + * units of the split booking back into real unit records. + */ + private async buildRemainderDto( + contract: Contract, + outstanding: SplitOutstanding, + splitBookingId: string, + day: string, + ): Promise { + const dto: CreateBookingUnderContractDto = { scheduledDate: day }; + + if (contract.freightType !== 'CONTAINER') { + const tons = outstanding.bulk?.outstanding ?? 0; + dto.bulkLines = [{ cargoWeightTons: tons }]; + return dto; + } + + // Container: recover the deferred units per size from the split booking's + // soft-deleted rows and reshape into DTO units. + const containers: NonNullable = []; + for (const [size, { outstanding: need }] of outstanding.bySize) { + if (need <= 0) continue; + const units = await this.recoverDeferredUnits(splitBookingId, size, need); + if (units.length < need) { + throw new Error( + `size ${size}: recovered ${units.length} deferred container(s) but ` + + `${need} are outstanding`, + ); + } + const line: NonNullable[number] = { + containerSize: size, + quantity: need, + units, + }; + line.hazardousQuantity = units.filter((u) => u.isHazardous).length; + line.reeferQuantity = units.filter((u) => u.isReefer).length; + containers.push(line); + } + dto.containers = containers; + return dto; + } + + /** + * The `need` deferred container units of a given size for the split booking, + * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the + * LIFO trim in applySplit so the same physical containers deferred are the + * ones rebooked). Returns them as DTO units; does NOT restore the rows. + */ + private async recoverDeferredUnits( + splitBookingId: string, + containerSize: string, + need: number, + ): Promise { + // The line ids of this booking for this size (live + soft-deleted): units + // key on bookingContainerId, so gather every line of the size first. + const lines = await this.dataSource + .getRepository(BookingContainer) + .find({ + where: { bookingId: splitBookingId, containerSize }, + withDeleted: true, + select: { id: true }, + }); + const lineIds = lines.map((l) => l.id); + if (!lineIds.length) return []; + + // Only the DELETED units are the deferred ones (live units stayed on the + // paid part). Oldest-first to match the deferred set. + const deferred = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: lineIds.map((bookingContainerId) => ({ + bookingContainerId, + deletedAt: Not(IsNull()), + })), + withDeleted: true, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + take: need, + }); + + return deferred.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 1e1eb1695..3b2b257c8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -32,6 +32,7 @@ import { IntercityService } from './intercity.service'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingJourneyService } from './booking-journey.service'; import { BookingSplitService } from './booking-split.service'; +import { RemainderPlacementService } from './remainder-placement.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -79,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module'; WsAuthService, BookingWindowService, BookingSplitService, + RemainderPlacementService, IntercityService, BookingJourneyService, ], diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index e43351015..9fe86ab1e 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -15,10 +15,23 @@ import { } from "./cookies"; import type { AuthTokens } from "./types"; +declare module "axios" { + export interface AxiosRequestConfig { + /** + * When true, the response interceptor does NOT raise the global error modal + * for this request's failure. For calls the caller handles itself — e.g. a + * probe that is expected to 404 before falling back (GL clearance detail + * tries /contracts/:id then /bookings/:id). The rejection still propagates. + */ + suppressErrorModal?: boolean; + } +} + type RetriableRequest = { _retry?: boolean; headers?: Record; url?: string; + suppressErrorModal?: boolean; }; const api = axios.create({ @@ -100,8 +113,13 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/refresh-token") ) { // Surface the server's actual error message in the global error modal - // (401s are handled by the session-refresh flow, so skip them). - if (error.response && error.response.status !== 401) { + // (401s are handled by the session-refresh flow, so skip them). A request + // may opt out via `suppressErrorModal` when it handles the failure itself. + if ( + error.response && + error.response.status !== 401 && + !originalRequest?.suppressErrorModal + ) { const payload = extractApiErrorPayload(error); if (payload) emitApiError(payload); } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index daff48939..f68d6562b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { Alert, Badge, @@ -18,7 +18,6 @@ import { AlertCircle, ClipboardList, FileText, - PackagePlus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -61,9 +60,12 @@ type GlClearanceDetail = async function loadGlClearanceDetail(id: string): Promise { try { + // Probe the contract endpoints first; a booking-id row 404s here by design + // and falls back to the booking lookup below. Suppress the global error + // modal so that expected 404 never surfaces to the user. const [clearance, contract] = await Promise.all([ - contractsService.getClearance(id), - contractsService.getById(id), + contractsService.getClearance(id, { suppressErrorModal: true }), + contractsService.getById(id, { suppressErrorModal: true }), ]); return { kind: "contract", @@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise { /** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */ export default function GlClearanceDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { user } = useAuth(); const { view, viewer } = useFileViewer(); const [uploadKind, setUploadKind] = useState(null); @@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() { {hasRo ? "Replace RO" : "Upload RO"} )} - {canCompleteBooking && shipmentBooking ? ( - - ) : null} } /> diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index eb027dde7..7e07d2345 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -164,8 +164,11 @@ export const contractsService = { }; }, - getById: async (id: string): Promise => { - const response = await client.get(C.BY_ID(id)); + getById: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.BY_ID(id), opts); return unwrap(response.data) as Freight.IContract; }, @@ -258,8 +261,11 @@ export const contractsService = { }; }, - getClearance: async (id: string): Promise => { - const response = await client.get(C.CLEARANCE(id)); + getClearance: async ( + id: string, + opts?: { suppressErrorModal?: boolean }, + ): Promise => { + const response = await client.get(C.CLEARANCE(id), opts); return unwrap(response.data) as Freight.ContractClearanceView; },