mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding stop based booking configuration
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the
|
||||||
|
-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting
|
||||||
|
-- distance-proportional interpolation). Nullable — falls back to distance interpolation
|
||||||
|
-- when unset.
|
||||||
|
-- Uses IF NOT EXISTS following the pattern established in
|
||||||
|
-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations
|
||||||
|
-- checked in as empty "applied directly" placeholders that never reached the deployed DB.
|
||||||
|
|
||||||
|
ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER;
|
||||||
203
apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts
Normal file
203
apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Per-station check-in cutoff — proves booking closure is now based on each stop's own
|
||||||
|
* ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's
|
||||||
|
* overall departure. The regression this guards: before this change, all stops effectively
|
||||||
|
* shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one
|
||||||
|
* wrongly left open) together with the rest of the route.
|
||||||
|
*
|
||||||
|
* Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises
|
||||||
|
* the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService
|
||||||
|
* and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule
|
||||||
|
* → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're
|
||||||
|
* instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in
|
||||||
|
* money-integrity.e2e-spec.ts.
|
||||||
|
*/
|
||||||
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { TasksService } from "../src/modules/tasks/tasks.service";
|
||||||
|
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
|
||||||
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
|
import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
|
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
|
||||||
|
function asyncStub(): any {
|
||||||
|
return new Proxy({}, { get: () => async () => undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */
|
||||||
|
async function createTestSchedule(
|
||||||
|
harness: ServiceHarness,
|
||||||
|
schedules: SchedulesService,
|
||||||
|
opts: { trainNumber: string; departureAt: Date; arrivalAt: Date },
|
||||||
|
) {
|
||||||
|
const train = await harness.prisma.train.create({
|
||||||
|
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
|
||||||
|
});
|
||||||
|
const schedule = await schedules.createSchedule({
|
||||||
|
trainId: train.id,
|
||||||
|
routeId: IDS.route,
|
||||||
|
departureAt: opts.departureAt.toISOString(),
|
||||||
|
arrivalAt: opts.arrivalAt.toISOString(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Give the schedule a bookable coach so holdSeats has real seats to work with.
|
||||||
|
const coach = await harness.prisma.coach.create({
|
||||||
|
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
await harness.prisma.coachAssignment.create({
|
||||||
|
data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true },
|
||||||
|
});
|
||||||
|
const seats = await Promise.all(
|
||||||
|
["1A", "1B", "1C", "1D"].map((seatNumber, i) =>
|
||||||
|
harness.prisma.seat.create({
|
||||||
|
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { schedule, seats };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Check-in cutoff — arrival-time basis, per-station independence", () => {
|
||||||
|
let harness: ServiceHarness;
|
||||||
|
let schedulesService: SchedulesService;
|
||||||
|
let seatsService: SeatsService;
|
||||||
|
let tasksService: TasksService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
harness = await createServiceHarness();
|
||||||
|
schedulesService = await harness.moduleRef.resolve(SchedulesService);
|
||||||
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
|
tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await harness?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
|
||||||
|
// dep only 5 min out (createSchedule requires a future departureAt). Route-level default
|
||||||
|
// checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is
|
||||||
|
// already ~25 min in the past by the time this runs — but B, with a 60-min travel time from
|
||||||
|
// A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is
|
||||||
|
// still ~35 min in the future.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min
|
||||||
|
await harness.prisma.routeStop.update({
|
||||||
|
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||||
|
data: { travelMinutesToStop: 60 },
|
||||||
|
});
|
||||||
|
await harness.prisma.routeStop.update({
|
||||||
|
where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } },
|
||||||
|
data: { travelMinutesToStop: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { schedule, seats } = await createTestSchedule(harness, schedulesService, {
|
||||||
|
trainNumber: `CUTOFF-A-${Date.now()}`,
|
||||||
|
departureAt: dep,
|
||||||
|
arrivalAt: arr,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/cannot be held within/i);
|
||||||
|
|
||||||
|
const held = await seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }],
|
||||||
|
} as any);
|
||||||
|
expect(held).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => {
|
||||||
|
// Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact
|
||||||
|
// schedule's B segment would still be OPEN (see previous test), so a rejection here proves
|
||||||
|
// the stop-level override, not the default, is what's actually being applied.
|
||||||
|
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
|
||||||
|
await harness.prisma.routeStop.update({
|
||||||
|
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||||
|
data: { travelMinutesToStop: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule(harness, schedulesService, {
|
||||||
|
trainNumber: `CUTOFF-B-${Date.now()}`,
|
||||||
|
departureAt: dep,
|
||||||
|
arrivalAt: arr,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationB,
|
||||||
|
destinationStationId: IDS.stationC,
|
||||||
|
passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/cannot be held within 90 minute/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.routeStop.update({
|
||||||
|
where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } },
|
||||||
|
data: { travelMinutesToStop: 60 },
|
||||||
|
});
|
||||||
|
await harness.prisma.routeStop.update({
|
||||||
|
where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } },
|
||||||
|
data: { travelMinutesToStop: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule } = await createTestSchedule(harness, schedulesService, {
|
||||||
|
trainNumber: `CUTOFF-C-${Date.now()}`,
|
||||||
|
departureAt: dep,
|
||||||
|
arrivalAt: arr,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tasksService.syncScheduleStatuses();
|
||||||
|
|
||||||
|
const stopTimes = await harness.prisma.tripStopTime.findMany({
|
||||||
|
where: { scheduleId: schedule.id },
|
||||||
|
orderBy: { sequence: "asc" },
|
||||||
|
});
|
||||||
|
const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status]));
|
||||||
|
expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED");
|
||||||
|
expect(byStation[IDS.stationB]).toBe("OPEN");
|
||||||
|
expect(byStation[IDS.stationC]).toBe("OPEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop
|
||||||
|
|
||||||
|
const dep = new Date(Date.now() + 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention
|
||||||
|
const { schedule } = await createTestSchedule(harness, schedulesService, {
|
||||||
|
trainNumber: `CUTOFF-D-${Date.now()}`,
|
||||||
|
departureAt: dep,
|
||||||
|
arrivalAt: arr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopTimes = await harness.prisma.tripStopTime.findMany({
|
||||||
|
where: { scheduleId: schedule.id },
|
||||||
|
orderBy: { sequence: "asc" },
|
||||||
|
});
|
||||||
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
|
const bProgress = DISTANCE.B / DISTANCE.C;
|
||||||
|
const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress);
|
||||||
|
|
||||||
|
const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!;
|
||||||
|
expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/edr-passenger-web/backoffice/src/lib/timezone.ts
Normal file
21
apps/edr-passenger-web/backoffice/src/lib/timezone.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* The backend always interprets bare datetime strings (no timezone suffix) as East African
|
||||||
|
* Time (EAT, UTC+3) and stores everything as UTC — see parseEthiopianTime() in
|
||||||
|
* apps/edr-passenger-api/src/common/utils/timezone.utils.ts. These mirror that on the frontend
|
||||||
|
* so `<input type="datetime-local">` fields round-trip correctly regardless of the browser's
|
||||||
|
* own local timezone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return a UTC ISO string. */
|
||||||
|
export function eatLocalToISO(local: string): string {
|
||||||
|
if (!local) return '';
|
||||||
|
return new Date(local + ':00+03:00').toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a UTC ISO string/Date to a datetime-local value ("YYYY-MM-DDTHH:mm") in EAT (UTC+3). */
|
||||||
|
export function isoToEATLocal(iso: string | Date): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const utcMs = new Date(iso).getTime();
|
||||||
|
const eatMs = utcMs + 3 * 60 * 60 * 1000;
|
||||||
|
return new Date(eatMs).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
100
e2e-ui/specs/backoffice/checkin-cutoff.spec.ts
Normal file
100
e2e-ui/specs/backoffice/checkin-cutoff.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { API_URL, STATIONS, staffToken } from "../../fixtures/data";
|
||||||
|
import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track B — per-station check-in cutoff, driven by the backoffice route config API. Follows the
|
||||||
|
* same convention as config-validation.spec.ts: hits the passenger-api directly with a staff
|
||||||
|
* bearer token rather than driving the real DOM — the route-stop form has no data-testid hooks,
|
||||||
|
* so browser automation here would be selector-fragile for no extra coverage value.
|
||||||
|
*
|
||||||
|
* Creates its OWN route (not the shared ROUTE_ID from seed-ui) since updateRoute deletes and
|
||||||
|
* recreates all stops — mutating the shared fixture route would break every other spec that
|
||||||
|
* depends on its distances/times staying stable for the whole suite run.
|
||||||
|
*/
|
||||||
|
function auth() {
|
||||||
|
return { Authorization: `Bearer ${staffToken()}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival independently of route-wide departure", async ({ request }) => {
|
||||||
|
const routeRes = await request.post(`${API_URL}/routes`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: {
|
||||||
|
code: `E2E-CUTOFF-${Date.now()}`,
|
||||||
|
name: "E2E Check-in Cutoff Route",
|
||||||
|
effectiveFrom: "2020-01-01T00:00:00Z",
|
||||||
|
stops: [
|
||||||
|
{ stationId: STATIONS.A, sequence: 1 },
|
||||||
|
{ stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 },
|
||||||
|
{ stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(routeRes.ok()).toBeTruthy();
|
||||||
|
const route = (await routeRes.json())?.data ?? (await routeRes.json());
|
||||||
|
const routeId = route.id;
|
||||||
|
|
||||||
|
// Reuse the seeded coach via a route coach template so schedule creation auto-assigns real
|
||||||
|
// seats (createSchedule auto-applies any route coach template — see schedules.service.ts).
|
||||||
|
const templateRes = await request.put(`${API_URL}/routes/${routeId}/coaches`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] },
|
||||||
|
});
|
||||||
|
expect(templateRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// dep only 5 min out: A's cutoff (dep - 30min default) is already ~25 min in the past by the
|
||||||
|
// time this schedule is queried, but B's arrival (dep + 60min) keeps its own cutoff (arrival -
|
||||||
|
// 30min default) about 35 min in the future — proving the two stations close independently.
|
||||||
|
const dep = new Date(Date.now() + 5 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min
|
||||||
|
const scheduleRes = await request.post(`${API_URL}/schedules`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { trainId: UI_IDS.train, routeId, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
|
||||||
|
});
|
||||||
|
expect(scheduleRes.ok()).toBeTruthy();
|
||||||
|
const schedule = (await scheduleRes.json())?.data ?? (await scheduleRes.json());
|
||||||
|
|
||||||
|
const getRes = await request.get(`${API_URL}/schedules/${schedule.id}`, { headers: auth() });
|
||||||
|
expect(getRes.ok()).toBeTruthy();
|
||||||
|
const full = (await getRes.json())?.data ?? (await getRes.json());
|
||||||
|
const stopTimes: any[] = full.stopTimes ?? [];
|
||||||
|
const bStop = stopTimes.find((s) => s.stationId === STATIONS.B);
|
||||||
|
const cStop = stopTimes.find((s) => s.stationId === STATIONS.C);
|
||||||
|
|
||||||
|
expect(new Date(bStop.plannedArrivalAt).getTime()).toBe(dep.getTime() + 60 * 60_000);
|
||||||
|
expect(new Date(cStop.plannedArrivalAt).getTime()).toBe(arr.getTime()); // last stop locked to overall arrival
|
||||||
|
|
||||||
|
// A's own segment (no arrival — falls back to its departure) is already past its cutoff...
|
||||||
|
const rejectRes = await request.post(`${API_URL}/seats/hold`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: STATIONS.A,
|
||||||
|
destinationStationId: STATIONS.B,
|
||||||
|
passengers: [{ passengerId: "44444444-4444-4444-8444-444444444444", seatId: "00000000-0000-4000-8000-000000009999" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(rejectRes.status()).toBe(400);
|
||||||
|
expect((await rejectRes.json())?.message ?? "").toMatch(/cannot be held within/i);
|
||||||
|
|
||||||
|
// ...while B, whose own arrival is comfortably later, remains independently bookable.
|
||||||
|
const seatmapRes = await request.get(`${API_URL}/seats/seatmap/${schedule.id}`, { headers: auth() });
|
||||||
|
expect(seatmapRes.ok()).toBeTruthy();
|
||||||
|
const seatmap = (await seatmapRes.json())?.data ?? (await seatmapRes.json());
|
||||||
|
const seatId = seatmap.coaches?.[0]?.seats?.[0]?.id;
|
||||||
|
expect(seatId).toBeTruthy();
|
||||||
|
|
||||||
|
const holdRes = await request.post(`${API_URL}/seats/hold`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: STATIONS.B,
|
||||||
|
destinationStationId: STATIONS.C,
|
||||||
|
passengers: [{ passengerId: "55555555-5555-4555-8555-555555555555", seatId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(holdRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await request.delete(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }).catch(() => {});
|
||||||
|
await request.delete(`${API_URL}/routes/${routeId}?cascade=true`, { headers: auth() }).catch(() => {});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user