diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e7727a467..f5d335efa 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -440,10 +440,14 @@ export class TrainSchedulingService { * caller then computes its own times as before — nothing changes for the * single-schedule case). * - * The anchor is preferably a still-PRE_WINDOW sibling (its times are the live - * group clock). If every sibling has already opened, we still copy the earliest - * sibling's frozen times so the new train lines up with the group the customer - * already sees rather than drifting onto its own `now`-based clock. + * The anchor is the sibling that best represents where the GROUP currently is + * on its shared clock, so a train created mid-cycle joins the group AT its + * current phase (with the group's exact doc-review / payment deadlines) instead + * of restarting the whole cycle on its own `now`. When the group has advanced + * past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is + * the phase the joiner must adopt to see the same "N minutes left" the group + * already shows. When every sibling is still PRE_WINDOW we pick the + * earliest-opening one (the group's frozen open clock). */ private async findGroupWindowAnchor( manager: EntityManager, @@ -460,37 +464,67 @@ export class TrainSchedulingService { if (siblings.length === 0) return null; const withWindow = siblings.filter((s) => s.windowOpensAt != null); if (withWindow.length === 0) return null; + + // A group whose window is live (some sibling has moved past PRE_WINDOW but is + // not yet DONE) — the joiner must land in that same phase on the same clock. + const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT']; + const live = withWindow.filter( + (s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) && + s.windowPhase !== 'PRE_WINDOW', + ); + if (live.length > 0) { + // Most-advanced phase leads; ties broken by earliest open for determinism. + return live.reduce((best, s) => { + const a = PHASE_ORDER.indexOf(s.windowPhase!); + const b = PHASE_ORDER.indexOf(best.windowPhase!); + if (a !== b) return a > b ? s : best; + return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best; + }); + } + + // Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling + // defines the group clock (the one a customer would have seen first). const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW'); const pool = pending.length > 0 ? pending : withWindow; - // Earliest-opening sibling defines the group clock — deterministic and the - // one a customer would have seen first. return pool.reduce((earliest, s) => s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest, ); } /** - * The window fields (open/close times + frozen rule snapshot) an anchor sibling - * hands down to the rest of its group. The shared open/close instants make every - * schedule in the group advance through the SAME open, doc-review, payment, and - * close instants on the shared 10s tick — doc-review/payment ends are derived - * live from these shared times during phase advance, so they fall in sync. + * The full window state an anchor sibling hands down to a schedule JOINING its + * group. Copies not just the open/close times + frozen rule snapshot but the + * anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt, + * paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a + * train created mid-cycle show the EXACT SAME "N minutes left" as the rest of + * the group: it enters directly at the group's current phase with the group's + * shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own + * `now` and stamping its own later `paymentPhaseEndsAt`. * - * `targetDeparture` is the JOINING schedule's own departure: the shared times - * are clamped to it so a group whose trains depart at different times of the - * same day never hands an earlier-departing train a window that outlives its + * `targetDeparture` is the JOINING schedule's own departure: every time is + * clamped to it so a group whose trains depart at different times of the same + * day never hands an earlier-departing train a deadline that outlives its * departure (computeImport/ExportWindowTimes clamp to departure at source; this - * preserves that invariant when the anchor departed later). A window that would - * be entirely after this train's departure collapses to a zero-length window at - * departure — truthful, not a window that never closes. + * preserves that invariant when the anchor departed later). */ private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) { const cap = targetDeparture.getTime(); const clamp = (d: Date | null | undefined): Date | null => d == null ? null : d.getTime() > cap ? targetDeparture : d; return { + // Live phase + its deadlines — a mid-cycle joiner lands here directly. + windowPhase: anchor.windowPhase, + bookingWindowStatus: + anchor.bookingWindowStatus === 'FULL' + ? 'OPEN' + : anchor.bookingWindowStatus, + bookingCycleNo: anchor.bookingCycleNo, windowOpensAt: clamp(anchor.windowOpensAt), windowClosesAt: clamp(anchor.windowClosesAt), + docReviewEndsAt: clamp(anchor.docReviewEndsAt), + docReviewCompletedAt: clamp(anchor.docReviewCompletedAt), + paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt), + // Frozen rule snapshot. ruleWindowOpenHour: anchor.ruleWindowOpenHour, ruleWindowCloseHour: anchor.ruleWindowCloseHour, ruleWindowDurationHours: anchor.ruleWindowDurationHours,