diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 15c8ef19d..1dabe387c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1057,6 +1057,43 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Commercial bookings on the day's corridor whose operation request was NOT + * accepted by staff (still pending / changes / price-confirm) and are not yet + * linked to a train. These never reached FULLY_EXECUTED, so they never enter the + * batch pool; the window's doc-review end sweeps them to EXPIRED. Government + * bookings are excluded (they don't go through the customer window). + */ + findUnacceptedForRouteDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere('booking.is_government = false') + .andWhere( + `booking.status IN ( + 'OPERATION_REQUESTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'OPERATION_PRICE_PENDING_CONFIRM' + )`, + ) + .getMany(); + } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index bdb8d8341..a52da71e6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -22,6 +22,7 @@ describe('BookingBatchService — PAID reconcile', () => { findBatchPool: jest.Mock; findBatchPoolByRouteDay: jest.Mock; findBatchPoolByCorridorDay: jest.Mock; + findUnacceptedForRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -55,6 +56,7 @@ describe('BookingBatchService — PAID reconcile', () => { findBatchPool: jest.fn().mockResolvedValue([]), findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]), + findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -332,4 +334,177 @@ describe('BookingBatchService — PAID reconcile', () => { expect(notifier.payNow).not.toHaveBeenCalled(); }); }); + + describe('expireUnacceptedForRouteDay — doc-review sweep', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + + const pendingBooking = { + id: 'pending-1', + reference: 'BK-PENDING-1', + status: 'OPERATION_REQUEST_PENDING', + isGovernment: false, + originYardId, + destinationYardId, + } as unknown as Booking; + + beforeEach(() => { + // One fillable schedule on this corridor/day so corridorYardsForRouteDay + // resolves a non-empty yard set (legacy two-stop route → [origin, dest]). + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-1', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + }, + ]); + }); + + it('expires each un-accepted booking and clears its scheduled day', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith( + expect.arrayContaining([originYardId, destinationYardId]), + day, + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'pending-1', + expect.objectContaining({ + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + scheduledDate: null, + }), + ); + expect(notifier.expired).toHaveBeenCalledWith(pendingBooking); + }); + + it('is a no-op when nothing is un-accepted', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.expired).not.toHaveBeenCalled(); + }); + + it('does nothing when the route-day has no fillable schedule', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled(); + }); + }); + + describe('maybeOfferPartial — split-eligibility gate', () => { + const importGeneral = { + id: 'b1', + reference: 'b1', + isGovernment: false, + tradeDirection: 'IMPORT', + contractKind: 'GENERAL', + consolidationPartnerId: null, + } as unknown as Booking; + + const call = (booking: Booking, isPair: boolean): boolean => + ( + service as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(booking, isPair); + + it('allows IMPORT + GENERAL when splitService is present', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(importGeneral, false); + expect(eligible).toBe(true); + }); + + it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible( + { ...importGeneral, contractKind: 'ONE_TIME' } as Booking, + false, + ); + expect(eligible).toBe(true); + }); + + it('rejects when splitService is absent (default test service)', () => { + // `service` from the outer beforeEach was built without a splitService. + expect(call(importGeneral, false)).toBe(false); + }); + + it('rejects EXPORT, government, consolidated pairs, and other directions', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const check = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible.bind(withSplit); + + expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false); + expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false); + expect(check(importGeneral, true)).toBe(false); // consolidated pair + expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); + }); + }); }); 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 a7957f378..5cd7fde6e 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 @@ -1026,6 +1026,14 @@ export class BookingBatchService implements OnModuleInit { const units = this.groupConsolidatedPool(pool); let armed = false; + // TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause + // is confirmed. Logs the caps + pool so we can see which axis rejects unit #2. + this.logger.debug( + `[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` + + `maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` + + `poolSize=${pool.length} units=${units.length}`, + ); + for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; @@ -1037,6 +1045,12 @@ export class BookingBatchService implements OnModuleInit { // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + // TODO(change-c-diagnostics): remove once the capacity cause is confirmed. + this.logger.debug( + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + ); + if (!budget.fits(need, leg)) { if (isGov) { const freed = await this.preemptForGovernment( @@ -1048,6 +1062,18 @@ export class BookingBatchService implements OnModuleInit { ); if (!freed) continue; // still doesn't fit even after preempt } else { + // Doesn't fit whole. A split-eligible import booking is offered the part + // that fits in the remaining room (top-up path splits the boundary + // booking, mirroring fillRouteDay); otherwise skip and try the next. + const cand: { id: string; budget: CorridorBudget; armed: boolean } = { + id: scheduleId, + budget, + armed, + }; + if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + armed = cand.armed; + continue; + } continue; // skip a unit that exceeds weight/length/wagons, try the next } } @@ -1149,6 +1175,14 @@ export class BookingBatchService implements OnModuleInit { // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); + // TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause + // is confirmed. Shows each train's caps + the day pool size. + this.logger.debug( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` + + `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + + `poolSize=${pool.length} units=${units.length}`, + ); + for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; @@ -1167,6 +1201,18 @@ export class BookingBatchService implements OnModuleInit { return leg != null && t.budget.fits(need, leg); }); + // TODO(change-c-diagnostics): remove once the capacity cause is confirmed. + this.logger.debug( + `[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `targetTrain=${target?.id ?? "none"} ` + + `rooms=${trains + .map((t) => { + const leg = legOn(t); + return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`; + }) + .join(",")}`, + ); + if (!target && isGov) { // Government fits nowhere on its own — try to preempt commercial // on each corridor-matching train (earliest first) until one frees room. @@ -1188,38 +1234,14 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // A consolidated pair is placed whole or not at all — never split. - if (!isPair) { - // Fits no train whole. Import GENERAL-contract commercial bookings get a - // partial-capacity offer on the train with the most free wagons on the - // booking's own leg. - const partialTarget = trains - .map((t) => { - const leg = legOn(t); - return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null; - }) - .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) - .sort((a, b) => b.room.wagons - a.room.wagons)[0]; - if ( - partialTarget && - !booking.isGovernment && - booking.tradeDirection === "IMPORT" && - booking.contractKind === "GENERAL" && - this.splitService - ) { - const offered = await this.tryPartialOffer( - booking, - partialTarget.t.id, - partialTarget.room, - need, - ); - if (offered) { - partialTarget.t.budget.subtract(offered, partialTarget.leg); - partialTarget.t.armed = true; - continue; - } - } - } + // Fits no train whole. A split-eligible booking is offered the largest + // part that fits on the train with the most free wagons on its leg (this + // covers both "fits nowhere" and the boundary case where earlier bookings + // already consumed most of the room). Consolidated pairs / government / + // non-import never split — isSplitEligible guards that. Passing the live + // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. + const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + if (offered) continue; // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1246,6 +1268,57 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } + /** + * 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). + */ + private isSplitEligible(booking: Booking, isPair: boolean): boolean { + return ( + !isPair && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && + this.splitService != null + ); + } + + /** + * Offer the largest fitting part of a booking that does not fit any candidate + * train whole, on the train with the most free wagons on the booking's leg. + * Mutates the chosen candidate's budget + armed flag in place. Returns true when + * an offer was opened (caller should `continue` past this unit), false otherwise. + * Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg + * is computed per candidate from the booking's yards, so callers pass their live + * train entries and only leg-carrying trains are considered. + */ + private async maybeOfferPartial( + booking: Booking, + isPair: boolean, + candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + need: Capacity, + ): Promise { + if (!this.isSplitEligible(booking, isPair)) return false; + const target = candidates + .map((c) => { + const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); + return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null; + }) + .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) + .sort((a, b) => b.room.wagons - a.room.wagons)[0]; + if (!target) return false; + const offered = await this.tryPartialOffer( + booking, + target.c.id, + target.room, + need, + ); + if (!offered) return false; + target.c.budget.subtract(offered, target.leg); + target.c.armed = true; + return true; + } + /** * Offer the largest fitting part of an over-capacity booking as a partial * (split-on-payment). Returns the capacity the offer consumes, or null when no @@ -1659,6 +1732,79 @@ export class BookingBatchService implements OnModuleInit { this.notifier.expired(booking); } + /** + * Union of stop yards across the day's fillable schedules on this corridor — + * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings + * are covered. Empty when no fillable schedule exists for the group. + */ + private async corridorYardsForRouteDay( + group: RouteDayGroup, + ): Promise { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const yards = new Set(); + for (const schedule of corridor) { + if ( + schedule.scheduledDepartureDate == null || + eatDay(schedule.scheduledDepartureDate) !== group.day + ) { + continue; + } + for (const yardId of await this.stopsForSchedule(schedule)) { + yards.add(yardId); + } + } + return [...yards]; + } + + /** + * Sweep bookings on a route-day whose operation request staff did NOT accept by + * the time the window's document-review phase ends. They never reached + * FULLY_EXECUTED, so they never enter the batch — expire them (customer must + * rebook a new window). No reservation and no invoice exists yet at this stage, + * so this is a lighter expiry than `expire()`: just flip status + notify, and + * best-effort close any payable if one was issued early. Government/export are + * excluded by the query. + */ + async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise { + const corridorYards = await this.corridorYardsForRouteDay(group); + if (corridorYards.length === 0) return; + const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( + corridorYards, + group.day, + ); + for (const booking of unaccepted) { + await this.bookingsRepository.update(booking.id, { + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", + // Free the shipment day so the customer can rebook a fresh window. + scheduledDate: null, + } as never); + // Close any payable issued before doc-review end (normally none — the invoice + // is created at ops-accept, which by definition has not happened here). + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID") + .catch(() => undefined); + this.notifier.expired(booking); + this.logger.log( + `Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` + + `(${group.originYardId}->${group.destinationYardId} ${group.day})`, + ); + } + } + /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts new file mode 100644 index 000000000..6d706c423 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -0,0 +1,101 @@ +import { BookingSplitService } from './booking-split.service'; +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 { BookingBatchOffer } from './entities/booking-batch-offer.entity'; + +/** + * applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL + * (both the parent contract row and the booking's denormalized copy) so the split + * remainder can be rebooked. A GENERAL booking is left untouched. + */ +describe('BookingSplitService — applySplit ONE_TIME promotion', () => { + const bookingId = 'bk-1'; + const contractId = 'ct-1'; + const offerId = 'of-1'; + + const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => { + const offer = { + id: offerId, + bookingId, + status: 'OFFERED', + offeredWagons: 3, + totalWagons: 5, + offeredWeightTons: 30, + offeredAmount: 300, + offeredPricingBreakdown: {}, + offeredLines: null, + } as unknown as BookingBatchOffer; + + const bookingRepo = { + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue({ + id: bookingId, + contractId, + contractKind: bookingContractKind, + }), + find: jest.fn().mockResolvedValue([]), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + const contractRepo = { update: jest.fn().mockResolvedValue(undefined) }; + const offerRepo = { + findOne: jest.fn().mockResolvedValue(offer), + update: jest.fn().mockResolvedValue(undefined), + }; + const containerRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn(), + softDelete: jest.fn(), + }; + const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() }; + + const repoFor = (entity: unknown) => { + if (entity === Booking) return bookingRepo; + if (entity === Contract) return contractRepo; + if (entity === BookingBatchOffer) return offerRepo; + if (entity === BookingContainer) return containerRepo; + if (entity === BookingContainerUnit) return unitRepo; + return { find: jest.fn().mockResolvedValue([]), update: jest.fn() }; + }; + + const dataSource = { + getRepository: jest.fn(repoFor), + transaction: jest.fn(async (fn: (m: unknown) => Promise) => { + await fn({ getRepository: repoFor }); + }), + }; + + const service = new BookingSplitService( + dataSource as never, + {} as never, + {} as never, + { expirePayable: jest.fn() } as never, + { payNowPartial: jest.fn() } as never, + ); + return { service, bookingRepo, contractRepo }; + }; + + it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => { + const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); + + await service.applySplit(bookingId); + + expect(bookingRepo.update).toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + expect(contractRepo.update).toHaveBeenCalledWith( + contractId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + }); + + it('leaves a GENERAL booking untouched (no contract promotion)', async () => { + const { service, contractRepo } = buildService('GENERAL'); + + await service.applySplit(bookingId); + + expect(contractRepo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 2cd2df6d9..44886f116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -9,6 +9,7 @@ import { BillingService } from '../billing/billing.service'; 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 { BookingBatchOffer, OfferedLine, @@ -30,10 +31,11 @@ export interface SizedOffer { * pays, which is the act of accepting the split (applySplit). No payment → * offer expires and the booking stays whole. * - * Only GENERAL-contract commercial bookings are offered partials: the remainder + * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder * returns to the contract's quantity cap (derived live from booking_container * rows, so reducing the lines releases it automatically) and can be rebooked in - * any later window within contract validity. + * any later window within contract validity. A ONE_TIME contract is promoted to + * GENERAL on split (see applySplit) so its remainder is actually rebookable. */ @Injectable() export class BookingSplitService { @@ -245,6 +247,25 @@ export class BookingSplitService { pricingBreakdown: offer.offeredPricingBreakdown, } as never); + // A ONE_TIME contract permits a single active booking, which would block the + // split remainder from ever being rebooked. Promote the parent contract (and + // the booking's denormalized copy) to GENERAL so the leftover quantity draws + // down against the cap like any general contract, within the same validity. + const booking = await manager.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, contractId: true, contractKind: true }, + }); + if (booking?.contractKind === 'ONE_TIME') { + await manager + .getRepository(Booking) + .update(bookingId, { contractKind: 'GENERAL' } as never); + if (booking.contractId) { + await manager + .getRepository(Contract) + .update(booking.contractId, { contractKind: 'GENERAL' } as never); + } + } + await manager .getRepository(BookingBatchOffer) .update(offer.id, { status: 'APPLIED' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 429a03a05..0f600d549 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -263,14 +263,19 @@ export class BookingWindowService implements OnModuleInit { ) { const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); - // Run the batch: priority fill over the route-day pool, reserving pay windows - // (or allocating government) — skipped automatically for everyone who fits - // is handled inside the fill (all fit → all reserved → all notified). - await this.bookingBatchService.processRouteDay({ + const routeDay = { originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day: eatDay(schedule.scheduledDepartureDate), - }); + }; + // Doc review is over: bookings staff never accepted (still pending) can no + // longer make this train — expire them BEFORE the batch so they never + // compete for capacity and never reach the pool. + await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay(routeDay); this.logger.log( `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, ); diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx index 24c2772aa..9283fc070 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx @@ -18,14 +18,11 @@ import { HardDrive, Layers, Paperclip, - Pencil, - Plus, Search, Settings, - Trash2, X, } from "lucide-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -33,9 +30,7 @@ import { api } from "@/services/api"; import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings"; import { DataTable, type ColumnDef } from "@edr/ui-common"; -import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog"; import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog"; -import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog"; export default function FileUploadSettingsPage() { const [query, setQuery] = useState(""); @@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() { const { data, isLoading, isError, error, refetch } = useQuery( api.fileUploadSettings.list.queryOptions(), ); - const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions()); const fileUploadSettings = useMemo( () => (Array.isArray(data) ? data : []), @@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap`, }, + // Settings are seeded/fixed — staff may only update a setting's fields, + // not create, edit, or delete the settings themselves. cell: ({ row }) => { const setting = row.original; return ( @@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() { Fields - - - - - - - - deleteMutation.mutate({ id: setting.id })} - > - - - - ); }, }, ]; - }, [deleteMutation]); + }, []); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; @@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() { - - - } />